refactor: web-ai V1 assets → signal_v1/ (graduation prep)
Atomic mv of root V1 assets (main_server.py + modules/ + data/ + tests/ + entry scripts + docs + logs) into signal_v1/ subdirectory. load_dotenv() updated to load web-ai/.env explicitly via Path. Adds web-ai/CLAUDE.md (workspace guide) and web-ai/start.bat (signal_v1 entry wrapper). Prepares for signal_v2/ Phase 2. Tests: signal_v1/tests/unit baseline preserved (no regression). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
signal_v1/modules/__init__.py
Normal file
2
signal_v1/modules/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
# Initialize modules package
|
||||
445
signal_v1/modules/analysis/ai_council.py
Normal file
445
signal_v1/modules/analysis/ai_council.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
AI 전문가 회의 시스템 (Multi-Agent Council)
|
||||
- 4명의 전문가 에이전트가 독립 분석 후 의장 AI가 최종 결정
|
||||
- 코스피 레짐 기반 모델 교체 권고
|
||||
- process.py 분석 결과를 입력받아 심층 검토 수행
|
||||
|
||||
흐름:
|
||||
전문가 1~4 (각 역할별 Ollama 호출)
|
||||
↓
|
||||
의장 AI (전문가 의견 취합 + 최종 결정 + 모델 건전성 평가)
|
||||
↓
|
||||
CouncilDecision (결정 + 모델 교체 권고 + 회의록)
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Any
|
||||
|
||||
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertOpinion:
|
||||
"""개별 전문가 의견"""
|
||||
expert_name: str
|
||||
role: str
|
||||
decision: str # BUY / SELL / HOLD
|
||||
confidence: float # 0~1
|
||||
reasoning: str
|
||||
key_concern: str
|
||||
model_feedback: str # 현재 AI 모델 적합성 평가
|
||||
|
||||
|
||||
@dataclass
|
||||
class CouncilDecision:
|
||||
"""회의 최종 결정"""
|
||||
final_decision: str # BUY / SELL / HOLD
|
||||
consensus_score: float # 0~1 (1 = 만장일치)
|
||||
confidence: float # 0~1
|
||||
majority_reasoning: str # 주요 결정 근거
|
||||
dissenting_views: str # 소수 의견
|
||||
model_health_score: float # 0~1 (현재 모델 신뢰도)
|
||||
model_replacement_recommended: bool # 모델 교체 필요 여부
|
||||
recommended_model: str # 교체 권고 모델명
|
||||
council_summary: str # 회의 전체 요약
|
||||
expert_opinions: List[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
# 전문가 페르소나 정의
|
||||
_EXPERTS = [
|
||||
{
|
||||
"name": "기술분석가",
|
||||
"role": "technical",
|
||||
"persona": (
|
||||
"20년 경력의 코스피 전문 기술분석가. "
|
||||
"RSI, MACD, 볼린저밴드, 추세선, 거래량 분석을 주로 사용. "
|
||||
"단기 가격 모멘텀과 지지/저항 구간을 중시함."
|
||||
),
|
||||
"focus": (
|
||||
"RSI 과매수/과매도, 볼린저밴드 위치, ADX 추세 강도, "
|
||||
"거래량 급증, 멀티타임프레임 정렬 여부를 핵심 근거로 사용하세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "퀀트전문가",
|
||||
"role": "quant",
|
||||
"persona": (
|
||||
"AI/ML 기반 퀀트 투자 전문가. "
|
||||
"LSTM 예측 신뢰도, 통계적 유의성, 백테스트 성과를 중시. "
|
||||
"모델의 현재 시장 환경 적합성을 항상 평가함."
|
||||
),
|
||||
"focus": (
|
||||
"LSTM 신뢰도와 예측 방향을 중심으로 분석하세요. "
|
||||
"현재 코스피 레짐에서 LSTM v3 모델이 적합한지 반드시 평가하고, "
|
||||
"더 나은 대안 모델이 있으면 구체적으로 제안하세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "리스크관리자",
|
||||
"role": "risk",
|
||||
"persona": (
|
||||
"글로벌 헤지펀드 리스크 관리 전문가. "
|
||||
"포지션 사이징, 최대 낙폭(MDD), VaR, 손절 기준을 최우선으로 고려. "
|
||||
"수익보다 손실 방어를 먼저 생각함."
|
||||
),
|
||||
"focus": (
|
||||
"변동성 대비 포지션 크기 적절성, 손절 기준 타당성, "
|
||||
"현재 보유 중이라면 추가 하락 리스크를 집중 평가하세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "거시경제분석가",
|
||||
"role": "macro",
|
||||
"persona": (
|
||||
"글로벌 매크로 및 한국 증시 전문가. "
|
||||
"코스피 지수 수준, 원/달러 환율, 미국 금리, 외국인 수급을 중시. "
|
||||
"현재 시장이 역사적으로 어떤 위치인지 판단함."
|
||||
),
|
||||
"focus": (
|
||||
"코스피 지수 현재 수준이 역사적으로 어떤 의미인지, "
|
||||
"이 가격대에서 매수/보유가 타당한지 거시경제 관점에서 평가하세요."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_expert_prompt(expert: dict, ticker: str, data: dict) -> str:
|
||||
"""전문가 역할에 맞는 분석 프롬프트 생성"""
|
||||
kospi = data.get("kospi_price", 2500)
|
||||
regime_label = MarketRegimeDetector.get_regime_label(kospi)
|
||||
|
||||
base = (
|
||||
f"종목: {ticker} | 현재가: {data.get('current_price', 0):,.0f}원\n"
|
||||
f"코스피: {kospi:.0f} [{regime_label}]\n"
|
||||
f"시장상태: {data.get('macro_state', 'SAFE')}\n"
|
||||
f"---기술지표---\n"
|
||||
f"기술점수: {data.get('tech_score', 0.5):.3f} | "
|
||||
f"RSI: {data.get('rsi', 50):.1f} | ADX: {data.get('adx', 20):.1f}\n"
|
||||
f"변동성: {data.get('volatility', 2.0):.2f}% | BB위치: {data.get('bb_zone', '중간')}\n"
|
||||
f"MTF정렬: {data.get('mtf_alignment', 'N/A')}\n"
|
||||
f"---AI모델---\n"
|
||||
f"LSTM예측: {data.get('lstm_predicted', 0):,.0f}원 "
|
||||
f"(변화율: {data.get('lstm_change_rate', 0):+.2f}%)\n"
|
||||
f"LSTM신뢰도: {data.get('ai_confidence', 0.5):.2f} | "
|
||||
f"LSTM점수: {data.get('lstm_score', 0.5):.3f}\n"
|
||||
f"---수급/감성---\n"
|
||||
f"감성점수: {data.get('sentiment_score', 0.5):.3f} | "
|
||||
f"수급점수: {data.get('investor_score', 0):.3f}\n"
|
||||
f"외인순매수: {data.get('frgn_net_buy', 0):+,} "
|
||||
f"({data.get('consecutive_frgn_buy', 0)}일 연속)\n"
|
||||
f"---포지션---\n"
|
||||
f"보유중: {data.get('is_holding', False)} | "
|
||||
f"보유수익률: {data.get('holding_yield', 0):+.2f}%\n"
|
||||
f"통합점수: {data.get('total_score', 0.5):.3f}\n"
|
||||
)
|
||||
|
||||
role_addition = (
|
||||
f"\n당신은 {expert['persona']}\n"
|
||||
f"분석 초점: {expert['focus']}\n"
|
||||
)
|
||||
|
||||
output_format = (
|
||||
"\n반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 금지):\n"
|
||||
"{\n"
|
||||
' "decision": "BUY" 또는 "SELL" 또는 "HOLD",\n'
|
||||
' "confidence": 0.0~1.0,\n'
|
||||
' "reasoning": "주요 판단 근거 (1~2문장, 한국어)",\n'
|
||||
' "key_concern": "가장 우려되는 리스크 (1문장, 한국어)",\n'
|
||||
' "model_feedback": "현재 LSTM v3 모델이 이 시장 환경에서 적합한지 평가 (1문장)"\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
return base + role_addition + output_format
|
||||
|
||||
|
||||
def _build_chairman_prompt(
|
||||
ticker: str,
|
||||
opinions: List[ExpertOpinion],
|
||||
data: dict,
|
||||
regime: RegimeAnalysis,
|
||||
) -> str:
|
||||
"""의장 AI 최종 결정 프롬프트"""
|
||||
opinions_text = "\n".join([
|
||||
f"[{op.expert_name}] {op.decision} (확신도: {op.confidence:.2f})\n"
|
||||
f" 근거: {op.reasoning}\n"
|
||||
f" 우려: {op.key_concern}\n"
|
||||
f" 모델평가: {op.model_feedback}"
|
||||
for op in opinions
|
||||
])
|
||||
|
||||
votes = [op.decision for op in opinions]
|
||||
buy_n = votes.count("BUY")
|
||||
sell_n = votes.count("SELL")
|
||||
hold_n = votes.count("HOLD")
|
||||
avg_conf = sum(op.confidence for op in opinions) / max(len(opinions), 1)
|
||||
|
||||
return (
|
||||
"당신은 AI 투자 전문가 회의를 주재하는 의장입니다.\n\n"
|
||||
f"=== 종목: {ticker} ===\n"
|
||||
f"현재가: {data.get('current_price', 0):,.0f}원 | "
|
||||
f"코스피: {data.get('kospi_price', 2500):.0f}\n"
|
||||
f"시장 레짐: {regime.regime.value} ({regime.description})\n"
|
||||
f"레짐 권고: {regime.model_recommendation}\n\n"
|
||||
f"=== 전문가 의견 ===\n{opinions_text}\n\n"
|
||||
f"=== 투표: 매수 {buy_n} / 매도 {sell_n} / 보유 {hold_n} "
|
||||
f"(평균 확신도: {avg_conf:.2f}) ===\n\n"
|
||||
"당신의 임무:\n"
|
||||
"1. 4명 의견을 종합하여 최종 매매 결정\n"
|
||||
f"2. LSTM v3 모델이 코스피 {data.get('kospi_price', 2500):.0f} 레짐에서 적합한지 평가\n"
|
||||
"3. 필요 시 대안 모델 구체적으로 권고\n\n"
|
||||
"반드시 아래 JSON 형식으로만 응답하세요:\n"
|
||||
"{\n"
|
||||
' "final_decision": "BUY" 또는 "SELL" 또는 "HOLD",\n'
|
||||
' "consensus_score": 0.0~1.0,\n'
|
||||
' "confidence": 0.0~1.0,\n'
|
||||
' "majority_reasoning": "최종 결정 근거 2~3문장 (한국어)",\n'
|
||||
' "dissenting_views": "소수 의견 요약 (없으면 빈 문자열)",\n'
|
||||
' "model_health_score": 0.0~1.0,\n'
|
||||
' "model_replacement_recommended": true 또는 false,\n'
|
||||
' "recommended_model": "교체 권고 모델명 (없으면 \'현재 모델 유지\')",\n'
|
||||
' "council_summary": "회의 전체 요약 3~4문장 (한국어)"\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_response(raw: Optional[str]) -> Optional[dict]:
|
||||
"""LLM 응답에서 JSON 추출 (폴백 포함)"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
import re
|
||||
match = re.search(r'\{[\s\S]*\}', raw)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _vote_fallback(opinions: List[ExpertOpinion]) -> CouncilDecision:
|
||||
"""의장 AI 실패 시 단순 다수결 폴백"""
|
||||
from collections import Counter
|
||||
if not opinions:
|
||||
return CouncilDecision(
|
||||
final_decision="HOLD", consensus_score=0.5, confidence=0.5,
|
||||
majority_reasoning="분석 데이터 부족", dissenting_views="",
|
||||
model_health_score=0.5, model_replacement_recommended=False,
|
||||
recommended_model="현재 모델 유지",
|
||||
council_summary="전문가 의견 수집 실패로 HOLD 처리",
|
||||
)
|
||||
|
||||
votes = [op.decision for op in opinions]
|
||||
final = Counter(votes).most_common(1)[0][0]
|
||||
avg_conf = sum(op.confidence for op in opinions) / len(opinions)
|
||||
vote_counts = Counter(votes)
|
||||
consensus = vote_counts[final] / len(votes)
|
||||
|
||||
return CouncilDecision(
|
||||
final_decision=final,
|
||||
consensus_score=round(consensus, 3),
|
||||
confidence=round(avg_conf, 3),
|
||||
majority_reasoning=f"전문가 {vote_counts[final]}/{len(votes)} 다수결 결과",
|
||||
dissenting_views="",
|
||||
model_health_score=0.5,
|
||||
model_replacement_recommended=False,
|
||||
recommended_model="현재 모델 유지",
|
||||
council_summary="의장 AI 오류 - 전문가 투표로 대체",
|
||||
expert_opinions=[
|
||||
{"name": op.expert_name, "decision": op.decision,
|
||||
"confidence": op.confidence, "reasoning": op.reasoning}
|
||||
for op in opinions
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AICouncil:
|
||||
"""
|
||||
AI 전문가 회의 시스템
|
||||
|
||||
사용 방법:
|
||||
council = AICouncil(llm_client)
|
||||
decision = council.convene(ticker, analysis_data, regime_analysis)
|
||||
|
||||
fast_mode=True 시 전문가 생략, 의장 AI 단독 판단 (속도 약 4배 향상)
|
||||
llm_client: GeminiLLMClient 또는 OllamaManager (request_inference 인터페이스 공용)
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client: Any = None):
|
||||
self._ollama = llm_client # 내부 변수명 유지 (하위호환)
|
||||
|
||||
def _get_ollama(self) -> Any:
|
||||
if self._ollama is None:
|
||||
from modules.services.llm_client import get_llm_client
|
||||
self._ollama = get_llm_client()
|
||||
return self._ollama
|
||||
|
||||
def _ask_expert(self, expert: dict, ticker: str, data: dict) -> ExpertOpinion:
|
||||
"""단일 전문가 의견 수집"""
|
||||
prompt = _build_expert_prompt(expert, ticker, data)
|
||||
raw = self._get_ollama().request_inference(prompt)
|
||||
parsed = _parse_json_response(raw)
|
||||
|
||||
if parsed:
|
||||
return ExpertOpinion(
|
||||
expert_name=expert["name"],
|
||||
role=expert["role"],
|
||||
decision=str(parsed.get("decision", "HOLD")).upper(),
|
||||
confidence=float(parsed.get("confidence", 0.5)),
|
||||
reasoning=str(parsed.get("reasoning", "")),
|
||||
key_concern=str(parsed.get("key_concern", "")),
|
||||
model_feedback=str(parsed.get("model_feedback", "")),
|
||||
)
|
||||
|
||||
# 파싱 실패 → 중립
|
||||
print(f"[Council] {expert['name']} 응답 파싱 실패 → HOLD 처리")
|
||||
return ExpertOpinion(
|
||||
expert_name=expert["name"],
|
||||
role=expert["role"],
|
||||
decision="HOLD",
|
||||
confidence=0.5,
|
||||
reasoning="응답 파싱 실패",
|
||||
key_concern="",
|
||||
model_feedback="",
|
||||
)
|
||||
|
||||
def convene(
|
||||
self,
|
||||
ticker: str,
|
||||
analysis_data: dict,
|
||||
regime_analysis: Optional[RegimeAnalysis] = None,
|
||||
fast_mode: bool = True,
|
||||
) -> CouncilDecision:
|
||||
"""
|
||||
전문가 회의 소집 및 최종 결정
|
||||
|
||||
Args:
|
||||
ticker: 종목 코드
|
||||
analysis_data: process.py 분석 결과 딕셔너리
|
||||
regime_analysis: MarketRegimeDetector.detect() 결과
|
||||
fast_mode: True=의장 AI 단독(빠름), False=전문가 4명+의장(심층)
|
||||
|
||||
Returns:
|
||||
CouncilDecision
|
||||
"""
|
||||
# 레짐 기본값
|
||||
if regime_analysis is None:
|
||||
kospi = analysis_data.get("kospi_price", 2500)
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi)
|
||||
|
||||
expert_opinions: List[ExpertOpinion] = []
|
||||
|
||||
if not fast_mode:
|
||||
print(f"[Council] {ticker} - 전문가 회의 시작 (4명)")
|
||||
for expert in _EXPERTS:
|
||||
print(f"[Council] {expert['name']} 분석 중...")
|
||||
opinion = self._ask_expert(expert, ticker, analysis_data)
|
||||
expert_opinions.append(opinion)
|
||||
time.sleep(0.3) # Ollama 연속 요청 간격
|
||||
else:
|
||||
print(f"[Council] {ticker} - Fast mode (의장 단독)")
|
||||
|
||||
# 의장 AI 취합
|
||||
chairman_prompt = _build_chairman_prompt(
|
||||
ticker, expert_opinions, analysis_data, regime_analysis
|
||||
)
|
||||
raw_chairman = self._get_ollama().request_inference(chairman_prompt)
|
||||
parsed_chairman = _parse_json_response(raw_chairman)
|
||||
|
||||
if parsed_chairman:
|
||||
decision = CouncilDecision(
|
||||
final_decision=str(parsed_chairman.get("final_decision", "HOLD")).upper(),
|
||||
consensus_score=float(parsed_chairman.get("consensus_score", 0.5)),
|
||||
confidence=float(parsed_chairman.get("confidence", 0.5)),
|
||||
majority_reasoning=str(parsed_chairman.get("majority_reasoning", "")),
|
||||
dissenting_views=str(parsed_chairman.get("dissenting_views", "")),
|
||||
model_health_score=float(parsed_chairman.get("model_health_score", 0.7)),
|
||||
model_replacement_recommended=bool(
|
||||
parsed_chairman.get("model_replacement_recommended", False)
|
||||
),
|
||||
recommended_model=str(
|
||||
parsed_chairman.get("recommended_model", "현재 모델 유지")
|
||||
),
|
||||
council_summary=str(parsed_chairman.get("council_summary", "")),
|
||||
expert_opinions=[
|
||||
{
|
||||
"name": op.expert_name,
|
||||
"decision": op.decision,
|
||||
"confidence": op.confidence,
|
||||
"reasoning": op.reasoning,
|
||||
}
|
||||
for op in expert_opinions
|
||||
],
|
||||
)
|
||||
|
||||
status_icon = "⚠️" if decision.model_replacement_recommended else "✅"
|
||||
print(
|
||||
f"[Council] {ticker} → {decision.final_decision} "
|
||||
f"(합의율: {decision.consensus_score:.0%}, "
|
||||
f"모델건전성: {decision.model_health_score:.0%}) "
|
||||
f"{status_icon}"
|
||||
)
|
||||
if decision.model_replacement_recommended:
|
||||
print(f"[Council] 모델 교체 권고: {decision.recommended_model}")
|
||||
|
||||
return decision
|
||||
|
||||
# 의장 실패 → 투표 폴백
|
||||
print(f"[Council] {ticker} - 의장 AI 실패, 투표 폴백 사용")
|
||||
return _vote_fallback(expert_opinions)
|
||||
|
||||
def quick_validate(
|
||||
self,
|
||||
ticker: str,
|
||||
kospi_price: float,
|
||||
ai_confidence: float,
|
||||
backtest_sharpe: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
LLM 호출 없이 규칙 기반 빠른 모델 검증
|
||||
|
||||
Returns:
|
||||
{
|
||||
"regime": str,
|
||||
"model_ok": bool,
|
||||
"score": float,
|
||||
"recommendation": str,
|
||||
"should_replace": bool,
|
||||
}
|
||||
"""
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi_price)
|
||||
validation = MarketRegimeDetector.validate_model_for_regime(
|
||||
regime_analysis.regime,
|
||||
backtest_sharpe=backtest_sharpe,
|
||||
)
|
||||
|
||||
# AI 신뢰도 하락 시 추가 감점
|
||||
score = validation["confidence_score"]
|
||||
if ai_confidence < 0.4:
|
||||
score *= 0.8
|
||||
|
||||
return {
|
||||
"regime": regime_analysis.regime.value,
|
||||
"regime_description": regime_analysis.description,
|
||||
"model_ok": score >= 0.5 and not validation["should_replace"],
|
||||
"score": round(score, 3),
|
||||
"recommendation": validation["recommendation"],
|
||||
"should_replace": validation["should_replace"],
|
||||
"alternative_models": validation["alternative_models"],
|
||||
}
|
||||
|
||||
|
||||
# 전역 싱글톤
|
||||
_council_instance: Optional[AICouncil] = None
|
||||
|
||||
|
||||
def get_council(llm_client: Any = None) -> AICouncil:
|
||||
"""워커 프로세스 내 AICouncil 싱글톤 반환 (GeminiLLMClient 또는 OllamaManager 수용)"""
|
||||
global _council_instance
|
||||
if _council_instance is None:
|
||||
_council_instance = AICouncil(llm_client)
|
||||
return _council_instance
|
||||
274
signal_v1/modules/analysis/backtest.py
Normal file
274
signal_v1/modules/analysis/backtest.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
백테스팅 프레임워크 (v3.2 — Realism 보강)
|
||||
|
||||
개선 사항 (v3.2):
|
||||
1. 다음 봉 시가 체결 옵션 (look-ahead bias 제거)
|
||||
2. 증권거래세 (매도 시 0.2%, 수수료와 별개 부과)
|
||||
3. 거래량 기반 부분 체결 (한 봉 거래량의 N% 상한)
|
||||
4. Calmar, Payoff, Turnover 지표 추가
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
ticker: str
|
||||
entry_date: int
|
||||
entry_price: float
|
||||
exit_date: int
|
||||
exit_price: float
|
||||
qty: int
|
||||
direction: str = "LONG"
|
||||
|
||||
@property
|
||||
def pnl(self):
|
||||
if self.direction == "LONG":
|
||||
return (self.exit_price - self.entry_price) * self.qty
|
||||
return (self.entry_price - self.exit_price) * self.qty
|
||||
|
||||
@property
|
||||
def pnl_pct(self):
|
||||
return (self.exit_price - self.entry_price) / self.entry_price * 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
total_return_pct: float
|
||||
sharpe_ratio: float
|
||||
max_drawdown_pct: float
|
||||
win_rate: float
|
||||
avg_win_pct: float
|
||||
avg_loss_pct: float
|
||||
profit_factor: float
|
||||
total_trades: int
|
||||
winning_trades: int
|
||||
losing_trades: int
|
||||
calmar_ratio: float = 0.0
|
||||
payoff_ratio: float = 0.0 # 평균수익 / |평균손실|
|
||||
turnover_ratio: float = 0.0 # 총 매매대금 / 초기자본
|
||||
trades: List[Trade] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
"=" * 50,
|
||||
"📊 백테스팅 결과 (v3.2)",
|
||||
"=" * 50,
|
||||
f"총 수익률: {self.total_return_pct:+.2f}%",
|
||||
f"Sharpe Ratio: {self.sharpe_ratio:.3f}",
|
||||
f"Calmar Ratio: {self.calmar_ratio:.3f}",
|
||||
f"Max Drawdown: {self.max_drawdown_pct:.2f}%",
|
||||
f"승률: {self.win_rate:.1f}% ({self.winning_trades}/{self.total_trades})",
|
||||
f"평균 수익: {self.avg_win_pct:+.2f}%",
|
||||
f"평균 손실: {self.avg_loss_pct:.2f}%",
|
||||
f"손익비(PF): {self.profit_factor:.2f}",
|
||||
f"Payoff Ratio: {self.payoff_ratio:.2f}",
|
||||
f"Turnover: {self.turnover_ratio:.2f}x",
|
||||
"=" * 50,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class Backtester:
|
||||
"""
|
||||
OHLCV 기반 전략 백테스터.
|
||||
|
||||
체결 모델 (v3.2):
|
||||
- next_bar_open=True: 신호 발생 다음 봉 시가로 체결 (look-ahead 제거)
|
||||
- slippage: 체결가에 ±slippage_rate 적용
|
||||
- commission_rate: 매수/매도 양쪽에 부과 (증권사 수수료)
|
||||
- sell_tax_rate: 매도 시에만 부과 (증권거래세 0.2%)
|
||||
- max_volume_participation: 봉 거래량의 N% 이하로 체결 제한
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
initial_capital: float = 10_000_000,
|
||||
commission_rate: float = 0.00015,
|
||||
slippage_rate: float = 0.001,
|
||||
sell_tax_rate: float = 0.002,
|
||||
next_bar_open: bool = True,
|
||||
max_volume_participation: float = 0.01):
|
||||
self.initial_capital = initial_capital
|
||||
self.commission_rate = commission_rate
|
||||
self.slippage_rate = slippage_rate
|
||||
self.sell_tax_rate = sell_tax_rate
|
||||
self.next_bar_open = next_bar_open
|
||||
self.max_volume_participation = max_volume_participation
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 단일 종목
|
||||
# ──────────────────────────────────────────────
|
||||
def run(self, ohlcv_data: Dict, strategy_fn: Callable,
|
||||
ticker: str = "UNKNOWN", warmup: int = 60) -> BacktestResult:
|
||||
closes = np.array(ohlcv_data.get('close', []), dtype=float)
|
||||
opens = np.array(ohlcv_data.get('open', closes), dtype=float)
|
||||
highs = np.array(ohlcv_data.get('high', closes), dtype=float)
|
||||
lows = np.array(ohlcv_data.get('low', closes), dtype=float)
|
||||
volumes = np.array(ohlcv_data.get('volume', np.zeros_like(closes)), dtype=float)
|
||||
|
||||
n = len(closes)
|
||||
if n < warmup + 10:
|
||||
return self._empty_result()
|
||||
|
||||
capital = self.initial_capital
|
||||
position = 0
|
||||
entry_price = 0.0
|
||||
entry_idx = 0
|
||||
equity_curve = [capital]
|
||||
trades: List[Trade] = []
|
||||
total_turnover = 0.0 # 누적 매매대금
|
||||
|
||||
# 마지막 인덱스는 next-bar 체결 시 여유 필요
|
||||
last_signal_idx = n - 2 if self.next_bar_open else n - 1
|
||||
|
||||
for i in range(warmup, last_signal_idx + 1):
|
||||
slice_data = {
|
||||
'close': closes[:i+1].tolist(),
|
||||
'open': opens[:i+1].tolist(),
|
||||
'high': highs[:i+1].tolist(),
|
||||
'low': lows[:i+1].tolist(),
|
||||
'volume': volumes[:i+1].tolist(),
|
||||
}
|
||||
signal = "HOLD"
|
||||
try:
|
||||
signal = strategy_fn(slice_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 체결가 산출 — next_bar_open이면 i+1 시가, 아니면 i 종가
|
||||
fill_idx = i + 1 if self.next_bar_open and i + 1 < n else i
|
||||
base_price = opens[fill_idx] if self.next_bar_open else closes[fill_idx]
|
||||
fill_volume = volumes[fill_idx]
|
||||
|
||||
buy_price = base_price * (1 + self.slippage_rate)
|
||||
sell_price = base_price * (1 - self.slippage_rate)
|
||||
|
||||
if signal == "BUY" and position == 0:
|
||||
# 전액 투자 (수수료 포함 총비용 기준)
|
||||
raw_qty = int(capital / (buy_price * (1 + self.commission_rate)))
|
||||
# 거래량 상한 — 봉 거래량의 N%까지만 체결
|
||||
vol_cap = int(fill_volume * self.max_volume_participation)
|
||||
qty = min(raw_qty, vol_cap) if vol_cap > 0 else raw_qty
|
||||
if qty > 0:
|
||||
cost = qty * buy_price * (1 + self.commission_rate)
|
||||
capital -= cost
|
||||
position = qty
|
||||
entry_price = buy_price
|
||||
entry_idx = fill_idx
|
||||
total_turnover += qty * buy_price
|
||||
|
||||
elif signal == "SELL" and position > 0:
|
||||
# 매도: 수수료 + 증권거래세
|
||||
sell_cost_rate = self.commission_rate + self.sell_tax_rate
|
||||
vol_cap = int(fill_volume * self.max_volume_participation) if fill_volume > 0 else position
|
||||
exec_qty = min(position, vol_cap) if vol_cap > 0 else position
|
||||
proceeds = exec_qty * sell_price * (1 - sell_cost_rate)
|
||||
capital += proceeds
|
||||
total_turnover += exec_qty * sell_price
|
||||
trades.append(Trade(
|
||||
ticker=ticker,
|
||||
entry_date=entry_idx,
|
||||
entry_price=entry_price,
|
||||
exit_date=fill_idx,
|
||||
exit_price=sell_price,
|
||||
qty=exec_qty
|
||||
))
|
||||
position -= exec_qty
|
||||
if position == 0:
|
||||
entry_price = 0.0
|
||||
|
||||
current_equity = capital + (position * closes[i] if position > 0 else 0)
|
||||
equity_curve.append(current_equity)
|
||||
|
||||
# 미청산 포지션: 마지막 종가 기준 강제 청산 (수수료+세금 반영)
|
||||
if position > 0:
|
||||
last_price = closes[-1] * (1 - self.slippage_rate)
|
||||
sell_cost_rate = self.commission_rate + self.sell_tax_rate
|
||||
proceeds = position * last_price * (1 - sell_cost_rate)
|
||||
capital += proceeds
|
||||
total_turnover += position * last_price
|
||||
trades.append(Trade(
|
||||
ticker=ticker,
|
||||
entry_date=entry_idx,
|
||||
entry_price=entry_price,
|
||||
exit_date=n - 1,
|
||||
exit_price=last_price,
|
||||
qty=position
|
||||
))
|
||||
equity_curve[-1] = capital
|
||||
position = 0
|
||||
|
||||
return self._compute_metrics(equity_curve, trades, total_turnover)
|
||||
|
||||
def run_multi(self, ohlcv_dict: Dict[str, Dict], strategy_fn: Callable,
|
||||
warmup: int = 60) -> Dict[str, BacktestResult]:
|
||||
return {t: self.run(d, strategy_fn, t, warmup) for t, d in ohlcv_dict.items()}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 지표 계산
|
||||
# ──────────────────────────────────────────────
|
||||
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade],
|
||||
total_turnover: float) -> BacktestResult:
|
||||
equity = np.array(equity_curve, dtype=float)
|
||||
total_return_pct = (equity[-1] / equity[0] - 1) * 100
|
||||
|
||||
daily_returns = np.diff(equity) / (equity[:-1] + 1e-9)
|
||||
sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252) \
|
||||
if daily_returns.std() > 0 else 0.0
|
||||
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdowns = (equity - peak) / (peak + 1e-9) * 100
|
||||
max_drawdown = abs(drawdowns.min())
|
||||
|
||||
wins = [t for t in trades if t.pnl_pct > 0]
|
||||
losses = [t for t in trades if t.pnl_pct <= 0]
|
||||
|
||||
win_rate = len(wins) / len(trades) * 100 if trades else 0
|
||||
avg_win = float(np.mean([t.pnl_pct for t in wins])) if wins else 0.0
|
||||
avg_loss = float(np.mean([t.pnl_pct for t in losses])) if losses else 0.0
|
||||
|
||||
total_win = sum(t.pnl for t in wins)
|
||||
total_loss = abs(sum(t.pnl for t in losses))
|
||||
profit_factor = total_win / (total_loss + 1e-9)
|
||||
|
||||
# 신규 지표
|
||||
calmar = (total_return_pct / max_drawdown) if max_drawdown > 0 else 0.0
|
||||
payoff = (avg_win / abs(avg_loss)) if avg_loss != 0 else 0.0
|
||||
turnover_ratio = total_turnover / (self.initial_capital + 1e-9)
|
||||
|
||||
return BacktestResult(
|
||||
total_return_pct=round(total_return_pct, 2),
|
||||
sharpe_ratio=round(sharpe, 3),
|
||||
max_drawdown_pct=round(max_drawdown, 2),
|
||||
win_rate=round(win_rate, 1),
|
||||
avg_win_pct=round(avg_win, 2),
|
||||
avg_loss_pct=round(avg_loss, 2),
|
||||
profit_factor=round(profit_factor, 3),
|
||||
total_trades=len(trades),
|
||||
winning_trades=len(wins),
|
||||
losing_trades=len(losses),
|
||||
calmar_ratio=round(calmar, 3),
|
||||
payoff_ratio=round(payoff, 3),
|
||||
turnover_ratio=round(turnover_ratio, 3),
|
||||
trades=trades,
|
||||
)
|
||||
|
||||
def _empty_result(self) -> BacktestResult:
|
||||
return BacktestResult(
|
||||
total_return_pct=0.0, sharpe_ratio=0.0, max_drawdown_pct=0.0,
|
||||
win_rate=0.0, avg_win_pct=0.0, avg_loss_pct=0.0,
|
||||
profit_factor=0.0, total_trades=0, winning_trades=0, losing_trades=0
|
||||
)
|
||||
|
||||
|
||||
def compare_strategies(ohlcv_data: Dict, strategies: Dict[str, Callable],
|
||||
initial_capital: float = 10_000_000) -> Dict[str, BacktestResult]:
|
||||
bt = Backtester(initial_capital=initial_capital)
|
||||
results = {}
|
||||
for name, fn in strategies.items():
|
||||
results[name] = bt.run(ohlcv_data, fn)
|
||||
print(f"\n[{name}]")
|
||||
print(results[name].summary())
|
||||
return results
|
||||
728
signal_v1/modules/analysis/deep_learning.py
Normal file
728
signal_v1/modules/analysis/deep_learning.py
Normal file
@@ -0,0 +1,728 @@
|
||||
import os
|
||||
import time
|
||||
import pickle
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from collections import OrderedDict
|
||||
from sklearn.preprocessing import MinMaxScaler
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
# cuDNN 벤치마크 활성화 (고정 입력 크기에 대해 최적 커널 자동 선택)
|
||||
torch.backends.cudnn.benchmark = True
|
||||
|
||||
# 체크포인트 버전 (피처 수 변경 시 기존 모델 자동 재학습)
|
||||
CHECKPOINT_VERSION = "v3"
|
||||
INPUT_SIZE = 7 # close, open, high, low, volume_norm, rsi_14, macd_hist
|
||||
|
||||
|
||||
class Attention(nn.Module):
|
||||
def __init__(self, hidden_size):
|
||||
super(Attention, self).__init__()
|
||||
self.attn = nn.Linear(hidden_size, 1)
|
||||
|
||||
def forward(self, lstm_output):
|
||||
attn_weights = torch.softmax(self.attn(lstm_output), dim=1)
|
||||
context = torch.sum(attn_weights * lstm_output, dim=1)
|
||||
return context, attn_weights
|
||||
|
||||
|
||||
class AdvancedLSTM(nn.Module):
|
||||
def __init__(self, input_size=INPUT_SIZE, hidden_size=512, num_layers=4, output_size=1, dropout=0.3):
|
||||
super(AdvancedLSTM, self).__init__()
|
||||
self.hidden_size = hidden_size
|
||||
self.num_layers = num_layers
|
||||
|
||||
self.lstm = nn.LSTM(input_size, hidden_size, num_layers,
|
||||
batch_first=True, dropout=dropout)
|
||||
self.attention = Attention(hidden_size)
|
||||
self.fc = nn.Sequential(
|
||||
nn.Linear(hidden_size, hidden_size // 2),
|
||||
nn.ReLU(),
|
||||
nn.Dropout(dropout),
|
||||
nn.Linear(hidden_size // 2, hidden_size // 4),
|
||||
nn.ReLU(),
|
||||
nn.Linear(hidden_size // 4, output_size)
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
h0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device)
|
||||
c0 = torch.zeros(self.num_layers, x.size(0), self.hidden_size).to(x.device)
|
||||
lstm_out, _ = self.lstm(x, (h0, c0))
|
||||
context, _ = self.attention(lstm_out)
|
||||
out = self.fc(context)
|
||||
return out
|
||||
|
||||
|
||||
def _get_free_vram_gb():
|
||||
"""현재 GPU VRAM 여유량(GB) 반환"""
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
total = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
reserved = torch.cuda.memory_reserved(0) / 1024**3
|
||||
return total - reserved
|
||||
except Exception:
|
||||
pass
|
||||
return 99.0 # CUDA 없으면 언로드 불필요
|
||||
|
||||
|
||||
def _unload_ollama():
|
||||
"""LSTM 학습 전 Ollama 모델 언로드 (VRAM < 2GB 여유일 때만)"""
|
||||
free_vram = _get_free_vram_gb()
|
||||
if free_vram >= 2.0:
|
||||
print(f"[AI] Ollama 언로드 생략 (VRAM 여유 {free_vram:.1f}GB >= 2GB)")
|
||||
return
|
||||
try:
|
||||
import requests
|
||||
url = f"{Config.OLLAMA_API_URL}/api/generate"
|
||||
requests.post(url, json={
|
||||
"model": Config.OLLAMA_MODEL,
|
||||
"keep_alive": 0
|
||||
}, timeout=5)
|
||||
print(f"[AI] Ollama 언로드 (VRAM 여유 {free_vram:.1f}GB)")
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _preload_ollama():
|
||||
"""LSTM 학습 후 Ollama 모델 리로드 (언로드했던 경우만)"""
|
||||
free_vram = _get_free_vram_gb()
|
||||
if free_vram >= 2.0:
|
||||
return # 언로드하지 않았으니 리로드도 불필요
|
||||
try:
|
||||
import requests
|
||||
url = f"{Config.OLLAMA_API_URL}/api/generate"
|
||||
requests.post(url, json={
|
||||
"model": Config.OLLAMA_MODEL,
|
||||
"prompt": "",
|
||||
"keep_alive": "10m"
|
||||
}, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _log_gpu_memory(tag=""):
|
||||
"""GPU 메모리 사용량 로깅"""
|
||||
if torch.cuda.is_available():
|
||||
allocated = torch.cuda.memory_allocated(0) / 1024**3
|
||||
reserved = torch.cuda.memory_reserved(0) / 1024**3
|
||||
print(f"[AI GPU {tag}] Allocated: {allocated:.2f}GB / Reserved: {reserved:.2f}GB")
|
||||
|
||||
|
||||
def _compute_rsi(close_arr, period=14):
|
||||
"""RSI 계산 (numpy 기반)"""
|
||||
if len(close_arr) < period + 1:
|
||||
return np.full(len(close_arr), 50.0)
|
||||
delta = np.diff(close_arr, prepend=close_arr[0])
|
||||
gain = np.where(delta > 0, delta, 0.0)
|
||||
loss = np.where(delta < 0, -delta, 0.0)
|
||||
alpha = 1.0 / period
|
||||
rsi_arr = np.zeros(len(close_arr))
|
||||
avg_gain = gain[0]
|
||||
avg_loss = loss[0]
|
||||
for i in range(1, len(close_arr)):
|
||||
avg_gain = alpha * gain[i] + (1 - alpha) * avg_gain
|
||||
avg_loss = alpha * loss[i] + (1 - alpha) * avg_loss
|
||||
rs = avg_gain / (avg_loss + 1e-9)
|
||||
rsi_arr[i] = 100 - (100 / (1 + rs))
|
||||
return rsi_arr
|
||||
|
||||
|
||||
def _compute_macd_hist(close_arr, fast=12, slow=26, signal=9):
|
||||
"""MACD Histogram 계산 (numpy 기반)"""
|
||||
if len(close_arr) < slow + signal:
|
||||
return np.zeros(len(close_arr))
|
||||
ema_fast = np.zeros(len(close_arr))
|
||||
ema_slow = np.zeros(len(close_arr))
|
||||
alpha_f = 2 / (fast + 1)
|
||||
alpha_s = 2 / (slow + 1)
|
||||
ema_fast[0] = close_arr[0]
|
||||
ema_slow[0] = close_arr[0]
|
||||
for i in range(1, len(close_arr)):
|
||||
ema_fast[i] = alpha_f * close_arr[i] + (1 - alpha_f) * ema_fast[i - 1]
|
||||
ema_slow[i] = alpha_s * close_arr[i] + (1 - alpha_s) * ema_slow[i - 1]
|
||||
macd = ema_fast - ema_slow
|
||||
sig = np.zeros(len(close_arr))
|
||||
alpha_sig = 2 / (signal + 1)
|
||||
sig[0] = macd[0]
|
||||
for i in range(1, len(close_arr)):
|
||||
sig[i] = alpha_sig * macd[i] + (1 - alpha_sig) * sig[i - 1]
|
||||
return macd - sig
|
||||
|
||||
|
||||
def _build_feature_matrix(ohlcv_data):
|
||||
"""
|
||||
OHLCV 딕셔너리 → 7차원 numpy 피처 행렬 생성
|
||||
피처: [close, open, high, low, volume_norm, rsi_14, macd_hist]
|
||||
"""
|
||||
close = np.array(ohlcv_data.get('close', []), dtype=np.float64)
|
||||
open_ = np.array(ohlcv_data.get('open', close), dtype=np.float64)
|
||||
high = np.array(ohlcv_data.get('high', close), dtype=np.float64)
|
||||
low = np.array(ohlcv_data.get('low', close), dtype=np.float64)
|
||||
volume = np.array(ohlcv_data.get('volume', []), dtype=np.float64)
|
||||
|
||||
n = len(close)
|
||||
_degraded = []
|
||||
if len(open_) != n: open_ = close.copy(); _degraded.append('open')
|
||||
if len(high) != n: high = close.copy(); _degraded.append('high')
|
||||
if len(low) != n: low = close.copy(); _degraded.append('low')
|
||||
if _degraded:
|
||||
print(f"[LSTM] ⚠️ OHLCV 피처 불완전 ({', '.join(_degraded)} → close 대체). 예측 신뢰도 저하 가능")
|
||||
|
||||
# 거래량 정규화 (20일 이동평균 대비 비율, max 기준보다 정보량이 높음)
|
||||
if len(volume) == n and volume.max() > 0:
|
||||
vol_series = pd.Series(volume)
|
||||
vol_ma20 = vol_series.rolling(20, min_periods=1).mean().values
|
||||
volume_norm = volume / (vol_ma20 + 1e-9)
|
||||
volume_norm = np.clip(volume_norm, 0.0, 5.0) / 5.0 # 0~5배 → 0~1 정규화
|
||||
else:
|
||||
volume_norm = np.full(n, 0.2) # 데이터 없으면 중립값
|
||||
|
||||
rsi = _compute_rsi(close, period=14)
|
||||
rsi_norm = rsi / 100.0 # 0~1 정규화
|
||||
|
||||
macd_hist = _compute_macd_hist(close)
|
||||
|
||||
# 7차원 피처 스택 (n x 7)
|
||||
features = np.column_stack([close, open_, high, low, volume_norm, rsi_norm, macd_hist])
|
||||
return features # shape: (n, 7)
|
||||
|
||||
|
||||
class PricePredictor:
|
||||
"""
|
||||
[v3.0] 주가 예측 Deep Learning 모델 (GPU 최적화)
|
||||
- 7차원 멀티피처 LSTM (close/open/high/low/vol_norm/rsi/macd_hist)
|
||||
- feature_scaler(6개) + target_scaler(1개) 분리
|
||||
- 데이터 누수 수정: train 데이터로만 fit
|
||||
- 체크포인트에 scaler 상태 저장/로드
|
||||
- VRAM 여유량 기반 Ollama 언로드 (충분하면 생략)
|
||||
"""
|
||||
def __init__(self):
|
||||
self.feature_scaler = MinMaxScaler(feature_range=(0, 1)) # 입력 6개 피처
|
||||
self.target_scaler = MinMaxScaler(feature_range=(0, 1)) # 타겟: close 가격
|
||||
|
||||
self.hidden_size = 512
|
||||
self.num_layers = 4
|
||||
|
||||
self.model = AdvancedLSTM(input_size=INPUT_SIZE, hidden_size=self.hidden_size,
|
||||
num_layers=self.num_layers, dropout=0.3)
|
||||
self.criterion = nn.MSELoss()
|
||||
|
||||
self.device = torch.device('cpu')
|
||||
self.use_amp = False
|
||||
|
||||
if torch.cuda.is_available():
|
||||
try:
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
|
||||
self.device = torch.device('cuda')
|
||||
self.model.to(self.device)
|
||||
|
||||
if torch.cuda.get_device_capability(0)[0] >= 7:
|
||||
self.use_amp = True
|
||||
|
||||
# Warm-up
|
||||
dummy = torch.zeros(1, 60, INPUT_SIZE, device=self.device)
|
||||
with torch.no_grad():
|
||||
_ = self.model(dummy)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
print(f"[AI] GPU Mode: {gpu_name} ({vram_gb:.1f}GB)"
|
||||
f" | FP16={'ON' if self.use_amp else 'OFF'}"
|
||||
f" | Features={INPUT_SIZE} | cuDNN Benchmark=ON")
|
||||
_log_gpu_memory("init")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AI] GPU Init Failed ({e}), falling back to CPU")
|
||||
self.device = torch.device('cpu')
|
||||
self.model.to(self.device)
|
||||
else:
|
||||
print("[AI] No CUDA GPU detected. Running on CPU.")
|
||||
|
||||
self.optimizer = torch.optim.AdamW(self.model.parameters(), lr=0.001, weight_decay=1e-4)
|
||||
self.lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
||||
self.optimizer, mode='min', factor=0.5, patience=7, min_lr=1e-6
|
||||
)
|
||||
self.scaler_amp = torch.amp.GradScaler('cuda') if self.use_amp else None
|
||||
|
||||
self.batch_size = 64
|
||||
self.max_epochs = 200
|
||||
self.seq_length = 60
|
||||
self.patience = 15
|
||||
self.max_grad_norm = 1.0
|
||||
|
||||
self.training_status = {
|
||||
"is_training": False,
|
||||
"loss": 0.0,
|
||||
"current_ticker": None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def verify_hardware():
|
||||
if torch.cuda.is_available():
|
||||
try:
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
vram_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
|
||||
print(f"[AI Check] {gpu_name} ({vram_gb:.1f}GB VRAM) | cuDNN={torch.backends.cudnn.is_available()}"
|
||||
f" | Features={INPUT_SIZE}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[AI Check] GPU Error: {e}")
|
||||
return False
|
||||
print("[AI Check] No GPU. CPU Mode.")
|
||||
return False
|
||||
|
||||
def _get_checkpoint_path(self, ticker):
|
||||
return os.path.join(Config.MODEL_DIR, f"{ticker}_lstm_{CHECKPOINT_VERSION}.pt")
|
||||
|
||||
def _load_checkpoint(self, ticker):
|
||||
path = self._get_checkpoint_path(ticker)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
checkpoint = torch.load(path, map_location=self.device, weights_only=False)
|
||||
# 버전 체크 (v3 이전 체크포인트는 재학습)
|
||||
if checkpoint.get('version', '') != CHECKPOINT_VERSION:
|
||||
print(f"[AI] Checkpoint version mismatch ({ticker}): 재학습 필요")
|
||||
return False
|
||||
self.model.load_state_dict(checkpoint['model_state_dict'])
|
||||
self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
|
||||
# scaler 복원
|
||||
if 'feature_scaler' in checkpoint:
|
||||
self.feature_scaler = pickle.loads(checkpoint['feature_scaler'])
|
||||
if 'target_scaler' in checkpoint:
|
||||
self.target_scaler = pickle.loads(checkpoint['target_scaler'])
|
||||
print(f"[AI] Checkpoint loaded: {ticker} (v3, 7-features)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[AI] Checkpoint load failed ({ticker}): {e}")
|
||||
return False
|
||||
|
||||
def _save_checkpoint(self, ticker, epoch, loss):
|
||||
path = self._get_checkpoint_path(ticker)
|
||||
try:
|
||||
torch.save({
|
||||
'version': CHECKPOINT_VERSION,
|
||||
'model_state_dict': self.model.state_dict(),
|
||||
'optimizer_state_dict': self.optimizer.state_dict(),
|
||||
'epoch': epoch,
|
||||
'loss': loss,
|
||||
'feature_scaler': pickle.dumps(self.feature_scaler),
|
||||
'target_scaler': pickle.dumps(self.target_scaler)
|
||||
}, path)
|
||||
except Exception as e:
|
||||
print(f"[AI] Checkpoint save failed ({ticker}): {e}")
|
||||
|
||||
def _is_checkpoint_fresh(self, ticker, max_age=None):
|
||||
"""체크포인트가 최근에 학습된 것인지 확인 (쿨다운 판단)"""
|
||||
if not ticker:
|
||||
return False
|
||||
path = self._get_checkpoint_path(ticker)
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
age = time.time() - os.path.getmtime(path)
|
||||
threshold = max_age if max_age is not None else Config.LSTM_COOLDOWN
|
||||
return age < threshold
|
||||
|
||||
def _prepare_scaled_features(self, features, split_point):
|
||||
"""
|
||||
피처 스케일링 (누수 방지: train split으로만 fit)
|
||||
features: (n, 7) numpy array
|
||||
split_point: train/val 분리 인덱스
|
||||
|
||||
Returns:
|
||||
scaled_features: (n, 7) 스케일된 전체 피처
|
||||
scaled_close: (n, 1) 스케일된 close (타겟용)
|
||||
"""
|
||||
# 6개 입력 피처 (close 포함 open/high/low/vol_norm/rsi/macd_hist)
|
||||
# + 타겟은 close만 별도 scaler
|
||||
input_features = features[:, :] # (n, 7) 전체 7개 피처 입력용
|
||||
target_close = features[:, 0:1] # (n, 1) close만 타겟용
|
||||
|
||||
# train 데이터로만 fit (데이터 누수 방지)
|
||||
self.feature_scaler.fit(input_features[:split_point])
|
||||
self.target_scaler.fit(target_close[:split_point])
|
||||
|
||||
scaled_features = self.feature_scaler.transform(input_features)
|
||||
scaled_close = self.target_scaler.transform(target_close)
|
||||
|
||||
return scaled_features, scaled_close
|
||||
|
||||
def _predict_only(self, ohlcv_data, ticker=None):
|
||||
"""학습 없이 현재 체크포인트로만 빠른 예측 (쿨다운 중 사용)"""
|
||||
prices = ohlcv_data.get('close', []) if isinstance(ohlcv_data, dict) else ohlcv_data
|
||||
if len(prices) < self.seq_length:
|
||||
return None
|
||||
try:
|
||||
features = _build_feature_matrix(
|
||||
ohlcv_data if isinstance(ohlcv_data, dict) else {'close': prices}
|
||||
)
|
||||
if len(features) < self.seq_length:
|
||||
return None
|
||||
|
||||
scaled = self.feature_scaler.transform(features)
|
||||
last_seq = torch.FloatTensor(scaled[-self.seq_length:]).unsqueeze(0).to(self.device)
|
||||
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
if self.use_amp:
|
||||
with torch.amp.autocast('cuda'):
|
||||
pred_scaled = self.model(last_seq)
|
||||
else:
|
||||
pred_scaled = self.model(last_seq)
|
||||
|
||||
predicted_price = self.target_scaler.inverse_transform(
|
||||
pred_scaled.cpu().float().numpy())[0][0]
|
||||
|
||||
current_price = prices[-1]
|
||||
trend = "UP" if predicted_price > current_price else "DOWN"
|
||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||
|
||||
cached_loss = self.training_status.get("loss", 0.5)
|
||||
# 캐시 신뢰도: 마지막 학습 loss 기반 동적 계산 (고정값 제거)
|
||||
cached_conf = min(0.70, 1.0 / (1.0 + (cached_loss * 200)))
|
||||
print(f"[AI] {ticker or '?'}: 쿨다운 중 → 캐시 예측 사용 "
|
||||
f"({predicted_price:.0f} / {change_rate:+.2f}% / conf={cached_conf:.2f})")
|
||||
return {
|
||||
"current": current_price,
|
||||
"predicted": float(predicted_price),
|
||||
"change_rate": round(change_rate, 2),
|
||||
"trend": trend,
|
||||
"loss": cached_loss,
|
||||
"val_loss": cached_loss,
|
||||
"confidence": round(cached_conf, 2),
|
||||
"epochs": 0,
|
||||
"device": str(self.device),
|
||||
"lr": self.optimizer.param_groups[0]['lr'],
|
||||
"cached": True
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[AI] _predict_only 실패 ({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def train_and_predict(self, ohlcv_data, forecast_days=1, ticker=None):
|
||||
"""
|
||||
[v3.0] 7차원 멀티피처 LSTM 학습 + 예측
|
||||
ohlcv_data: dict {'close':[], 'open':[], 'high':[], 'low':[], 'volume':[]}
|
||||
또는 list (하위 호환: close 리스트)
|
||||
"""
|
||||
# 하위 호환: list 형태
|
||||
if isinstance(ohlcv_data, list):
|
||||
ohlcv_data = {'close': ohlcv_data}
|
||||
|
||||
prices = ohlcv_data.get('close', [])
|
||||
if len(prices) < (self.seq_length + 10):
|
||||
return None
|
||||
|
||||
# ===== 쿨다운 체크 =====
|
||||
if self._is_checkpoint_fresh(ticker):
|
||||
has_ckpt = self._load_checkpoint(ticker)
|
||||
if has_ckpt:
|
||||
result = self._predict_only(ohlcv_data, ticker)
|
||||
if result:
|
||||
return result
|
||||
|
||||
is_gpu = self.device.type == 'cuda'
|
||||
|
||||
# VRAM 여유량 기반 Ollama 언로드
|
||||
if is_gpu:
|
||||
_unload_ollama()
|
||||
torch.cuda.empty_cache()
|
||||
_log_gpu_memory("pre-train")
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
# 1. 피처 행렬 구성 (n, 7)
|
||||
features = _build_feature_matrix(ohlcv_data)
|
||||
if len(features) < (self.seq_length + 10):
|
||||
return None
|
||||
|
||||
n = len(features)
|
||||
split_point = int(n * 0.8)
|
||||
|
||||
# 2. 스케일링 (train 데이터로만 fit → 누수 방지)
|
||||
scaled_features, scaled_close = self._prepare_scaled_features(features, split_point)
|
||||
|
||||
# 3. 시퀀스 생성
|
||||
x_seqs, y_seqs = [], []
|
||||
for i in range(n - self.seq_length):
|
||||
x_seqs.append(scaled_features[i:i + self.seq_length]) # (seq, 7)
|
||||
y_seqs.append(scaled_close[i + self.seq_length]) # (1,)
|
||||
|
||||
x_all = torch.FloatTensor(np.array(x_seqs)).to(self.device)
|
||||
y_all = torch.FloatTensor(np.array(y_seqs)).to(self.device)
|
||||
|
||||
# validation split (80/20)
|
||||
seq_split = int(len(x_all) * 0.8)
|
||||
x_train, y_train = x_all[:seq_split], y_all[:seq_split]
|
||||
x_val, y_val = x_all[seq_split:], y_all[seq_split:]
|
||||
dataset_size = len(x_train)
|
||||
|
||||
# 4. 체크포인트 로드
|
||||
has_checkpoint = self._load_checkpoint(ticker) if ticker else False
|
||||
max_epochs = Config.LSTM_FAST_EPOCHS if has_checkpoint else self.max_epochs
|
||||
|
||||
self.optimizer.param_groups[0]['lr'] = 0.001 if not has_checkpoint else 0.0005
|
||||
self.lr_scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
||||
self.optimizer, mode='min', factor=0.5, patience=7, min_lr=1e-6
|
||||
)
|
||||
|
||||
# 5. 학습
|
||||
self.model.train()
|
||||
self.training_status["is_training"] = True
|
||||
if ticker:
|
||||
self.training_status["current_ticker"] = ticker
|
||||
|
||||
best_val_loss = float('inf')
|
||||
best_model_state = None
|
||||
patience_counter = 0
|
||||
final_loss = 0.0
|
||||
actual_epochs = 0
|
||||
|
||||
for epoch in range(max_epochs):
|
||||
perm = torch.randperm(dataset_size, device=self.device)
|
||||
x_shuffled = x_train[perm]
|
||||
y_shuffled = y_train[perm]
|
||||
|
||||
epoch_loss = 0.0
|
||||
steps = 0
|
||||
|
||||
for i in range(0, dataset_size, self.batch_size):
|
||||
end = min(i + self.batch_size, dataset_size)
|
||||
batch_x = x_shuffled[i:end]
|
||||
batch_y = y_shuffled[i:end]
|
||||
|
||||
self.optimizer.zero_grad(set_to_none=True)
|
||||
|
||||
if self.use_amp:
|
||||
with torch.amp.autocast('cuda'):
|
||||
outputs = self.model(batch_x)
|
||||
loss = self.criterion(outputs, batch_y)
|
||||
self.scaler_amp.scale(loss).backward()
|
||||
self.scaler_amp.unscale_(self.optimizer)
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.max_grad_norm)
|
||||
self.scaler_amp.step(self.optimizer)
|
||||
self.scaler_amp.update()
|
||||
else:
|
||||
outputs = self.model(batch_x)
|
||||
loss = self.criterion(outputs, batch_y)
|
||||
loss.backward()
|
||||
torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.max_grad_norm)
|
||||
self.optimizer.step()
|
||||
|
||||
epoch_loss += loss.item()
|
||||
steps += 1
|
||||
|
||||
train_loss = epoch_loss / max(1, steps)
|
||||
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
if self.use_amp:
|
||||
with torch.amp.autocast('cuda'):
|
||||
val_out = self.model(x_val)
|
||||
val_loss = self.criterion(val_out, y_val).item()
|
||||
else:
|
||||
val_out = self.model(x_val)
|
||||
val_loss = self.criterion(val_out, y_val).item()
|
||||
self.model.train()
|
||||
|
||||
self.lr_scheduler.step(val_loss)
|
||||
final_loss = train_loss
|
||||
actual_epochs = epoch + 1
|
||||
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
patience_counter = 0
|
||||
best_model_state = {k: v.clone() for k, v in self.model.state_dict().items()}
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= self.patience:
|
||||
break
|
||||
|
||||
if best_model_state:
|
||||
self.model.load_state_dict(best_model_state)
|
||||
|
||||
self.training_status["is_training"] = False
|
||||
self.training_status["loss"] = final_loss
|
||||
|
||||
if is_gpu:
|
||||
torch.cuda.synchronize()
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
print(f"[AI] {ticker or '?'}: {actual_epochs} epochs in {elapsed:.1f}s"
|
||||
f" | loss={final_loss:.6f} val={best_val_loss:.6f}"
|
||||
f" | device={self.device} | features={INPUT_SIZE}")
|
||||
|
||||
# 6. 체크포인트 저장 (scaler 포함)
|
||||
if ticker:
|
||||
self._save_checkpoint(ticker, actual_epochs, final_loss)
|
||||
|
||||
# 7. 예측
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
last_seq = torch.FloatTensor(
|
||||
scaled_features[-self.seq_length:]
|
||||
).unsqueeze(0).to(self.device)
|
||||
|
||||
if self.use_amp:
|
||||
with torch.amp.autocast('cuda'):
|
||||
predicted_scaled = self.model(last_seq)
|
||||
else:
|
||||
predicted_scaled = self.model(last_seq)
|
||||
|
||||
predicted_price = self.target_scaler.inverse_transform(
|
||||
predicted_scaled.cpu().float().numpy())[0][0]
|
||||
|
||||
# 8. GPU 정리 + Ollama 리로드
|
||||
if is_gpu:
|
||||
del x_all, y_all, x_train, y_train, x_val, y_val
|
||||
torch.cuda.empty_cache()
|
||||
_log_gpu_memory("post-train")
|
||||
_preload_ollama()
|
||||
|
||||
current_price = prices[-1]
|
||||
trend = "UP" if predicted_price > current_price else "DOWN"
|
||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||
|
||||
# ── 신뢰도 계산 (보수적 버전) ──────────────────────────────
|
||||
# val_loss 기반: 0.001→0.74, 0.003→0.62, 0.01→0.50 (이전보다 보수적)
|
||||
loss_confidence = 1.0 / (1.0 + (best_val_loss * 200))
|
||||
|
||||
# 오버피팅 페널티
|
||||
overfit_ratio = final_loss / (best_val_loss + 1e-9)
|
||||
if overfit_ratio < 0.5:
|
||||
overfit_penalty = 0.65 # 심각한 언더피팅
|
||||
elif overfit_ratio > 2.5:
|
||||
overfit_penalty = 0.75 # 오버피팅
|
||||
else:
|
||||
overfit_penalty = 1.0
|
||||
|
||||
# 에포크 수 기반 수렴 판단
|
||||
epoch_factor = 1.0
|
||||
if actual_epochs < 10:
|
||||
epoch_factor = 0.55 # 너무 이른 수렴 → 불신뢰
|
||||
elif actual_epochs >= max_epochs:
|
||||
epoch_factor = 0.80 # 미수렴 → 부분 신뢰
|
||||
|
||||
# 최종 상한: 0.80 (이전 0.95보다 보수적 — LSTM 70% 가중치 남발 방지)
|
||||
confidence = min(0.80, loss_confidence * overfit_penalty * epoch_factor)
|
||||
|
||||
return {
|
||||
"current": current_price,
|
||||
"predicted": float(predicted_price),
|
||||
"change_rate": round(change_rate, 2),
|
||||
"trend": trend,
|
||||
"loss": final_loss,
|
||||
"val_loss": best_val_loss,
|
||||
"confidence": round(confidence, 2),
|
||||
"epochs": actual_epochs,
|
||||
"device": str(self.device),
|
||||
"lr": self.optimizer.param_groups[0]['lr']
|
||||
}
|
||||
|
||||
def batch_predict(self, ohlcv_dict):
|
||||
"""여러 종목을 배치로 예측 (체크포인트 있는 종목만)"""
|
||||
results = {}
|
||||
seqs = []
|
||||
metas = []
|
||||
|
||||
for ticker, ohlcv_data in ohlcv_dict.items():
|
||||
if isinstance(ohlcv_data, list):
|
||||
ohlcv_data = {'close': ohlcv_data}
|
||||
prices = ohlcv_data.get('close', [])
|
||||
if len(prices) < (self.seq_length + 10):
|
||||
results[ticker] = None
|
||||
continue
|
||||
|
||||
try:
|
||||
features = _build_feature_matrix(ohlcv_data)
|
||||
scaled = self.feature_scaler.transform(features)
|
||||
seq = torch.FloatTensor(scaled[-self.seq_length:]).unsqueeze(0)
|
||||
seqs.append(seq)
|
||||
metas.append((ticker, prices[-1]))
|
||||
except Exception:
|
||||
results[ticker] = None
|
||||
|
||||
if not seqs:
|
||||
return results
|
||||
|
||||
batch = torch.cat(seqs, dim=0).to(self.device)
|
||||
|
||||
self.model.eval()
|
||||
with torch.no_grad():
|
||||
if self.use_amp:
|
||||
with torch.amp.autocast('cuda'):
|
||||
preds = self.model(batch)
|
||||
else:
|
||||
preds = self.model(batch)
|
||||
|
||||
preds_cpu = preds.cpu().float().numpy()
|
||||
|
||||
for i, (ticker, current_price) in enumerate(metas):
|
||||
predicted_price = self.target_scaler.inverse_transform(preds_cpu[i:i+1])[0][0]
|
||||
trend = "UP" if predicted_price > current_price else "DOWN"
|
||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||
|
||||
results[ticker] = {
|
||||
"current": current_price,
|
||||
"predicted": float(predicted_price),
|
||||
"change_rate": round(change_rate, 2),
|
||||
"trend": trend
|
||||
}
|
||||
|
||||
if self.device.type == 'cuda':
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""
|
||||
[v3.0] 종목별 LSTM 모델 격리 (LRU 퇴출, max_models=5)
|
||||
- 싱글톤 패턴: 워커 프로세스마다 하나의 Registry 유지
|
||||
- 16GB VRAM에서 LSTM 5개(~250MB) + Ollama 7B(~4GB) 동시 적재 가능
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, max_models=5):
|
||||
self.max_models = max_models
|
||||
self._predictors = OrderedDict() # ticker -> PricePredictor (LRU 순서)
|
||||
print(f"[ModelRegistry] Initialized (max_models={max_models})")
|
||||
|
||||
def get_predictor(self, ticker):
|
||||
"""종목별 PricePredictor 반환 (없으면 생성, LRU 관리)"""
|
||||
if ticker in self._predictors:
|
||||
# LRU: 접근 시 맨 뒤로 이동
|
||||
self._predictors.move_to_end(ticker)
|
||||
return self._predictors[ticker]
|
||||
|
||||
# 용량 초과 시 가장 오래된 것 퇴출
|
||||
if len(self._predictors) >= self.max_models:
|
||||
oldest_ticker, oldest_pred = self._predictors.popitem(last=False)
|
||||
print(f"[ModelRegistry] Evicted {oldest_ticker} (LRU, {len(self._predictors)}/{self.max_models})")
|
||||
del oldest_pred
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
predictor = PricePredictor()
|
||||
self._predictors[ticker] = predictor
|
||||
print(f"[ModelRegistry] Created predictor for {ticker} ({len(self._predictors)}/{self.max_models})")
|
||||
return predictor
|
||||
|
||||
def has_predictor(self, ticker):
|
||||
return ticker in self._predictors
|
||||
|
||||
def clear(self):
|
||||
"""모든 모델 해제"""
|
||||
self._predictors.clear()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
416
signal_v1/modules/analysis/ensemble.py
Normal file
416
signal_v1/modules/analysis/ensemble.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
앙상블 예측 모듈 (Phase 3-3)
|
||||
- LSTM + 기술지표 + LLM 감성 → 적응형 가중치
|
||||
- 과거 매매 결과 기반 가중치 자동 조정
|
||||
- Kelly Criterion 기반 포지션 비중 계산
|
||||
- process.py의 하드코딩된 w_tech/w_news/w_ai 대체
|
||||
- 파일 mtime 기반 cross-process 동기화 (워커 ↔ 메인 프로세스)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalWeights:
|
||||
"""앙상블 가중치"""
|
||||
tech: float = 0.35
|
||||
sentiment: float = 0.30
|
||||
lstm: float = 0.35
|
||||
|
||||
# 각 신호의 허용 범위
|
||||
MIN_WEIGHT = 0.10
|
||||
MAX_WEIGHT = 0.65
|
||||
|
||||
def normalize(self):
|
||||
"""
|
||||
경계 보존 정규화 (합=1, MIN≤각값≤MAX 동시 보장)
|
||||
|
||||
단순 1/2차 정규화는 경계 위반을 반복 유발하므로
|
||||
반복 배분 알고리즘(Water-Filling) 사용:
|
||||
1. 단순 정규화 (비율 유지)
|
||||
2. 경계 위반 값 → 경계에 고정, 나머지에 잔여 비중 비례 배분
|
||||
3. 모든 값이 경계 내에 들 때까지 반복 (최대 10회)
|
||||
"""
|
||||
MIN, MAX = self.MIN_WEIGHT, self.MAX_WEIGHT
|
||||
vals = [max(MIN * 0.1, self.tech),
|
||||
max(MIN * 0.1, self.sentiment),
|
||||
max(MIN * 0.1, self.lstm)]
|
||||
|
||||
for _ in range(10):
|
||||
total = sum(vals)
|
||||
if total > 0:
|
||||
vals = [v / total for v in vals]
|
||||
|
||||
fixed = [None, None, None]
|
||||
has_violation = False
|
||||
for i, v in enumerate(vals):
|
||||
if v < MIN:
|
||||
fixed[i] = MIN
|
||||
has_violation = True
|
||||
elif v > MAX:
|
||||
fixed[i] = MAX
|
||||
has_violation = True
|
||||
|
||||
if not has_violation:
|
||||
break
|
||||
|
||||
fixed_sum = sum(f for f in fixed if f is not None)
|
||||
remaining = 1.0 - fixed_sum
|
||||
free = [(i, vals[i]) for i, f in enumerate(fixed) if f is None]
|
||||
free_sum = sum(v for _, v in free)
|
||||
|
||||
new_vals = list(fixed)
|
||||
if free and free_sum > 0:
|
||||
factor = remaining / free_sum
|
||||
for i, v in free:
|
||||
new_vals[i] = v * factor
|
||||
elif free:
|
||||
per = remaining / len(free)
|
||||
for i, _ in free:
|
||||
new_vals[i] = per
|
||||
|
||||
vals = [v if v is not None else 0.0 for v in new_vals]
|
||||
|
||||
self.tech, self.sentiment, self.lstm = vals
|
||||
return self
|
||||
|
||||
def to_dict(self):
|
||||
return {"tech": self.tech, "sentiment": self.sentiment, "lstm": self.lstm}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
return cls(tech=d.get("tech", 0.35),
|
||||
sentiment=d.get("sentiment", 0.30),
|
||||
lstm=d.get("lstm", 0.35))
|
||||
|
||||
|
||||
class AdaptiveEnsemble:
|
||||
"""
|
||||
적응형 앙상블 가중치 관리자
|
||||
|
||||
핵심 로직:
|
||||
1. 종목별 최근 N 매매의 결과를 추적
|
||||
2. 어떤 신호가 정확했는지 소급 평가 (크기 가중 정확도)
|
||||
3. 정확도가 높은 신호의 가중치를 점진적으로 증가
|
||||
4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리
|
||||
5. Kelly Criterion 기반 최적 포지션 비중 제공
|
||||
6. 파일 mtime 기반 cross-process 동기화 (워커 프로세스 갱신)
|
||||
"""
|
||||
|
||||
def __init__(self, history_file=None, max_history=50):
|
||||
self.max_history = max_history
|
||||
self.history_file = history_file or os.path.join(
|
||||
Config.DATA_DIR, "ensemble_history.json"
|
||||
)
|
||||
# {ticker: [{"tech_score": f, "sentiment_score": f, "lstm_score": f,
|
||||
# "decision": str, "outcome": float}, ...]}
|
||||
self._trade_history: Dict[str, list] = {}
|
||||
# {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger" | "default"
|
||||
self._context_weights: Dict[str, SignalWeights] = {
|
||||
"strong_trend": SignalWeights(tech=0.50, sentiment=0.20, lstm=0.30),
|
||||
"sideways": SignalWeights(tech=0.30, sentiment=0.40, lstm=0.30),
|
||||
"danger": SignalWeights(tech=0.20, sentiment=0.50, lstm=0.30),
|
||||
"default": SignalWeights(tech=0.35, sentiment=0.30, lstm=0.35),
|
||||
}
|
||||
self._load_mtime: float = 0.0 # 마지막 파일 로드 시각
|
||||
self._load()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 파일 I/O
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(self.history_file):
|
||||
try:
|
||||
with open(self.history_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self._trade_history = data.get("history", {})
|
||||
weights_raw = data.get("weights", {})
|
||||
for ctx, w in weights_raw.items():
|
||||
self._context_weights[ctx] = SignalWeights.from_dict(w)
|
||||
self._load_mtime = os.path.getmtime(self.history_file)
|
||||
except Exception as e:
|
||||
print(f"[Ensemble] Load failed: {e}")
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
data = {
|
||||
"history": {k: v[-self.max_history:] for k, v in self._trade_history.items()},
|
||||
"weights": {ctx: w.to_dict() for ctx, w in self._context_weights.items()}
|
||||
}
|
||||
with open(self.history_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
self._load_mtime = os.path.getmtime(self.history_file)
|
||||
except Exception as e:
|
||||
print(f"[Ensemble] Save failed: {e}")
|
||||
|
||||
def reload_if_stale(self):
|
||||
"""
|
||||
파일이 마지막 로드 이후 수정되었으면 재로드.
|
||||
워커 프로세스가 메인 프로세스의 record_trade 결과를 반영하기 위해 사용.
|
||||
"""
|
||||
if not os.path.exists(self.history_file):
|
||||
return
|
||||
try:
|
||||
mtime = os.path.getmtime(self.history_file)
|
||||
if mtime > self._load_mtime:
|
||||
self._load()
|
||||
print("[Ensemble] 파일 변경 감지, 가중치 재로드")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 컨텍스트 & 가중치
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_context(self, adx: float, macro_state: str) -> str:
|
||||
"""현재 시장 컨텍스트 결정"""
|
||||
if macro_state == "DANGER":
|
||||
return "danger"
|
||||
if adx >= 25:
|
||||
return "strong_trend"
|
||||
if adx < 20:
|
||||
return "sideways"
|
||||
return "default"
|
||||
|
||||
def get_weights(self, ticker: str, adx: float = 20.0,
|
||||
macro_state: str = "SAFE",
|
||||
ai_confidence: float = 0.5) -> SignalWeights:
|
||||
"""
|
||||
종목 + 시장 컨텍스트에 맞는 가중치 반환
|
||||
|
||||
1. 컨텍스트별 기준 가중치 선택
|
||||
2. AI 신뢰도 높으면 lstm 가중치 보정
|
||||
3. 종목별 학습 결과 반영 (크기 가중 정확도 사용)
|
||||
"""
|
||||
context = self.get_context(adx, macro_state)
|
||||
base = self._context_weights.get(context, self._context_weights["default"])
|
||||
|
||||
ticker_history = self._trade_history.get(ticker, [])
|
||||
adjusted = SignalWeights(tech=base.tech, sentiment=base.sentiment, lstm=base.lstm)
|
||||
|
||||
if len(ticker_history) >= 5:
|
||||
recent = ticker_history[-10:]
|
||||
# _accuracy_weighted: 방향 일치 + 수익 크기 가중 반영 (단순 binary X)
|
||||
tech_acc = self._accuracy_weighted(
|
||||
[h.get("tech_score", 0.5) for h in recent],
|
||||
[h["outcome"] for h in recent])
|
||||
news_acc = self._accuracy_weighted(
|
||||
[h.get("sentiment_score", 0.5) for h in recent],
|
||||
[h["outcome"] for h in recent])
|
||||
lstm_acc = self._accuracy_weighted(
|
||||
[h.get("lstm_score", 0.5) for h in recent],
|
||||
[h["outcome"] for h in recent])
|
||||
|
||||
alpha = 0.05 # 미세 조정폭 (±0.1 범위)
|
||||
adjusted.tech = max(0.10, min(0.60, base.tech + alpha * (tech_acc - 0.5)))
|
||||
adjusted.sentiment = max(0.10, min(0.60, base.sentiment + alpha * (news_acc - 0.5)))
|
||||
adjusted.lstm = max(0.10, min(0.60, base.lstm + alpha * (lstm_acc - 0.5)))
|
||||
|
||||
# AI 신뢰도 보정 (LSTM confidence 상한 0.80 기준 조정)
|
||||
if ai_confidence >= 0.75:
|
||||
adjusted.lstm = min(0.65, adjusted.lstm * 1.25)
|
||||
elif ai_confidence < 0.5:
|
||||
adjusted.lstm = max(0.10, adjusted.lstm * 0.75)
|
||||
|
||||
return adjusted.normalize()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 앙상블 점수
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def compute_ensemble_score(self, tech_score: float, sentiment_score: float,
|
||||
lstm_score: float, investor_score: float = 0.0,
|
||||
weights: Optional[SignalWeights] = None) -> float:
|
||||
"""
|
||||
앙상블 통합 점수 계산
|
||||
|
||||
Args:
|
||||
weights: 가중치 (None이면 기본값 사용)
|
||||
"""
|
||||
if weights is None:
|
||||
weights = SignalWeights()
|
||||
|
||||
total = (weights.tech * tech_score
|
||||
+ weights.sentiment * sentiment_score
|
||||
+ weights.lstm * lstm_score)
|
||||
|
||||
# 수급 가산점 (최대 +0.15)
|
||||
total += min(investor_score, 0.15)
|
||||
return min(1.0, max(0.0, total))
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Kelly Criterion
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def get_kelly_fraction(self, ticker: str = None, half_kelly: bool = True) -> float:
|
||||
"""
|
||||
Modified Kelly Criterion 기반 최적 투자 비중 계산
|
||||
|
||||
f* = (p * b - q) / b
|
||||
where:
|
||||
p = 과거 승리 거래 비율 (win rate)
|
||||
q = 1 - p
|
||||
b = 평균이익 / 평균손실 비율 (avg profit / avg loss, Risk-Reward)
|
||||
|
||||
Returns:
|
||||
0.03 ~ 0.25 범위의 Kelly 분수
|
||||
- half_kelly=True: 변동성 과대추정 보완을 위해 1/2 적용
|
||||
- 거래 데이터 < 10건: 보수적 기본값 0.08 반환
|
||||
"""
|
||||
# 해당 종목 우선, 없으면 전체 통합 히스토리 사용
|
||||
if ticker and ticker in self._trade_history:
|
||||
outcomes = [h["outcome"] for h in self._trade_history[ticker]
|
||||
if h.get("outcome") is not None]
|
||||
else:
|
||||
# 전체 종목 결과 통합 (시장 전반 win rate)
|
||||
outcomes = [
|
||||
h["outcome"]
|
||||
for records in self._trade_history.values()
|
||||
for h in records
|
||||
if h.get("outcome") is not None
|
||||
]
|
||||
|
||||
if len(outcomes) < 10:
|
||||
return 0.08 # 데이터 부족 → 보수적 8%
|
||||
|
||||
wins = [o for o in outcomes if o > 0]
|
||||
losses = [abs(o) for o in outcomes if o <= 0]
|
||||
|
||||
if not wins:
|
||||
return 0.03 # 승리 거래 없음 → 최소 비중
|
||||
if not losses:
|
||||
return 0.20 # 손실 거래 없음 → 낙관적이나 상한 제한
|
||||
|
||||
p = len(wins) / len(outcomes)
|
||||
q = 1.0 - p
|
||||
avg_win = sum(wins) / len(wins)
|
||||
avg_loss = sum(losses) / len(losses)
|
||||
|
||||
if avg_loss == 0:
|
||||
return 0.20
|
||||
|
||||
b = avg_win / avg_loss # Risk-Reward ratio
|
||||
kelly = (p * b - q) / b
|
||||
|
||||
if half_kelly:
|
||||
kelly /= 2.0 # Half-Kelly: 실제 활용 시 표준
|
||||
|
||||
result = max(0.03, min(0.25, kelly)) # 3% ~ 25% 범위 제한
|
||||
return result
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 거래 결과 기록 & 가중치 학습
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def record_trade(self, ticker: str, tech_score: float, sentiment_score: float,
|
||||
lstm_score: float, decision: str, outcome_pct: float):
|
||||
"""
|
||||
매매 결과 기록 → 가중치 학습 데이터 축적
|
||||
|
||||
Args:
|
||||
outcome_pct: 실현 수익률 (%). 양수=이익, 음수=손실
|
||||
"""
|
||||
if ticker not in self._trade_history:
|
||||
self._trade_history[ticker] = []
|
||||
|
||||
record = {
|
||||
"tech_score": tech_score,
|
||||
"sentiment_score": sentiment_score,
|
||||
"lstm_score": lstm_score,
|
||||
"decision": decision,
|
||||
"outcome": outcome_pct
|
||||
}
|
||||
self._trade_history[ticker].append(record)
|
||||
if len(self._trade_history[ticker]) > self.max_history:
|
||||
self._trade_history[ticker] = self._trade_history[ticker][-self.max_history:]
|
||||
|
||||
self._update_weights(ticker)
|
||||
self._save()
|
||||
|
||||
def _update_weights(self, ticker: str):
|
||||
"""
|
||||
종목별 성과를 반영해 컨텍스트 가중치 점진적 업데이트.
|
||||
|
||||
- 크기 가중 정확도(accuracy_weighted) 사용 → 큰 손실에 강한 패널티
|
||||
- 지수이동평균(alpha=0.10)으로 점진 반영 → 급격한 가중치 전환 방지
|
||||
- normalize() 후 재경계 적용 → 경계값 위반 방지
|
||||
"""
|
||||
history = self._trade_history.get(ticker, [])
|
||||
if len(history) < 5:
|
||||
return
|
||||
|
||||
recent = history[-10:]
|
||||
outcomes = [h["outcome"] for h in recent]
|
||||
|
||||
tech_acc = self._accuracy_weighted(
|
||||
[h.get("tech_score", 0.5) for h in recent], outcomes)
|
||||
news_acc = self._accuracy_weighted(
|
||||
[h.get("sentiment_score", 0.5) for h in recent], outcomes)
|
||||
lstm_acc = self._accuracy_weighted(
|
||||
[h.get("lstm_score", 0.5) for h in recent], outcomes)
|
||||
|
||||
alpha = 0.10 # EMA 계수 (10회 거래 후 완전 반영)
|
||||
|
||||
for ctx, w in self._context_weights.items():
|
||||
delta_tech = alpha * (tech_acc - 0.5) * 0.4 # 최대 ±0.02
|
||||
delta_news = alpha * (news_acc - 0.5) * 0.4
|
||||
delta_lstm = alpha * (lstm_acc - 0.5) * 0.4
|
||||
|
||||
# 경계 적용 → normalize (경계 재반영) → normalize (합=1 보장)
|
||||
w.tech = max(0.10, min(0.65, w.tech + delta_tech))
|
||||
w.sentiment = max(0.10, min(0.65, w.sentiment + delta_news))
|
||||
w.lstm = max(0.10, min(0.65, w.lstm + delta_lstm))
|
||||
w.normalize() # normalize() 내부에서 경계 재클램핑 + 2차 정규화 수행
|
||||
|
||||
print(f"[Ensemble] {ctx} tech={w.tech:.2f} news={w.sentiment:.2f} lstm={w.lstm:.2f} "
|
||||
f"(acc T={tech_acc:.2f} N={news_acc:.2f} L={lstm_acc:.2f})")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 정확도 지표
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _accuracy_weighted(scores: list, outcomes: list) -> float:
|
||||
"""
|
||||
신호-결과 크기 가중 정확도 (0.0~1.0, 0.5=무관)
|
||||
|
||||
- 단순 방향 일치(0/1)가 아닌 수익률 절댓값으로 가중
|
||||
- 큰 손실 예측 실패는 작은 이익 예측 성공보다 강하게 패널티
|
||||
"""
|
||||
if len(scores) < 3:
|
||||
return 0.5
|
||||
|
||||
total_weight = 0.0
|
||||
weighted_correct = 0.0
|
||||
|
||||
for s, o in zip(scores, outcomes):
|
||||
weight = max(1.0, abs(o)) # 수익률 절댓값 기반 가중치 (최소 1.0)
|
||||
total_weight += weight
|
||||
if (s >= 0.5 and o > 0) or (s < 0.5 and o <= 0):
|
||||
weighted_correct += weight
|
||||
|
||||
if total_weight == 0:
|
||||
return 0.5
|
||||
return weighted_correct / total_weight
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 전역 싱글톤 (프로세스별)
|
||||
# ──────────────────────────────────────────────
|
||||
_ensemble_instance: Optional[AdaptiveEnsemble] = None
|
||||
|
||||
|
||||
def get_ensemble() -> AdaptiveEnsemble:
|
||||
"""프로세스 내 싱글톤 앙상블 관리자 반환 (워커/메인 각각 독립 인스턴스)"""
|
||||
global _ensemble_instance
|
||||
if _ensemble_instance is None:
|
||||
_ensemble_instance = AdaptiveEnsemble()
|
||||
return _ensemble_instance
|
||||
421
signal_v1/modules/analysis/evaluator.py
Normal file
421
signal_v1/modules/analysis/evaluator.py
Normal file
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
성과 평가 엔진 - PerformanceEvaluator
|
||||
|
||||
기능:
|
||||
1. compute_metrics() - 핵심 성과 지표 계산
|
||||
2. get_grade() - 지표별 S/A/B/C/D/F 등급 산출
|
||||
3. generate_expert_panel() - Ollama LLM 5명 전문가 의견
|
||||
4. generate_weekly_report() - 텔레그램 HTML 주간 보고서
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from modules.utils.performance_db import PerformanceDB
|
||||
|
||||
|
||||
class PerformanceEvaluator:
|
||||
def __init__(self):
|
||||
self.perf_db = PerformanceDB()
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 1. 핵심 지표 계산
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def compute_metrics(self, snapshots, trades):
|
||||
"""성과 지표를 딕셔너리로 반환.
|
||||
|
||||
Args:
|
||||
snapshots (list): daily_snapshots 리스트
|
||||
trades (list): trade_records 리스트
|
||||
|
||||
Returns:
|
||||
dict: 지표 딕셔너리 (또는 {"error": ...})
|
||||
"""
|
||||
if not snapshots:
|
||||
return {"error": "스냅샷 데이터 없음 (운영 시작 후 첫 영업일까지 대기)"}
|
||||
|
||||
metrics = {}
|
||||
|
||||
# ── 수익률 ──────────────────────────────
|
||||
initial = snapshots[0].get("total_eval", 0)
|
||||
current = snapshots[-1].get("total_eval", 0)
|
||||
metrics["total_return_pct"] = round(
|
||||
(current - initial) / initial * 100, 2) if initial > 0 else 0.0
|
||||
|
||||
cutoff_7 = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
recent_snaps = [s for s in snapshots if s.get("date", "") >= cutoff_7]
|
||||
if len(recent_snaps) >= 2:
|
||||
w_init = recent_snaps[0].get("total_eval", 0)
|
||||
w_curr = recent_snaps[-1].get("total_eval", 0)
|
||||
metrics["weekly_return_pct"] = round(
|
||||
(w_curr - w_init) / w_init * 100, 2) if w_init > 0 else 0.0
|
||||
else:
|
||||
metrics["weekly_return_pct"] = 0.0
|
||||
|
||||
# ── 리스크 지표 ──────────────────────────
|
||||
daily_returns = [s.get("daily_return_pct", 0.0) / 100.0 for s in snapshots]
|
||||
|
||||
if len(daily_returns) >= 2:
|
||||
mean_daily = sum(daily_returns) / len(daily_returns)
|
||||
variance = sum((r - mean_daily) ** 2 for r in daily_returns) / len(daily_returns)
|
||||
std_daily = math.sqrt(variance) if variance > 0 else 0.0
|
||||
annual_return = mean_daily * 252
|
||||
|
||||
# Sharpe Ratio
|
||||
if std_daily > 0:
|
||||
metrics["sharpe_ratio"] = round(
|
||||
annual_return / (std_daily * math.sqrt(252)), 3)
|
||||
else:
|
||||
metrics["sharpe_ratio"] = 0.0
|
||||
|
||||
# Sortino Ratio (하방 편차만 사용)
|
||||
downside = [r for r in daily_returns if r < 0]
|
||||
if downside:
|
||||
dv = sum(r ** 2 for r in downside) / len(downside)
|
||||
ds = math.sqrt(dv)
|
||||
metrics["sortino_ratio"] = round(
|
||||
annual_return / (ds * math.sqrt(252)), 3) if ds > 0 else 0.0
|
||||
else:
|
||||
metrics["sortino_ratio"] = 10.0 # 손실 없음
|
||||
|
||||
# Max Drawdown
|
||||
peak = snapshots[0].get("total_eval", 0)
|
||||
max_dd = 0.0
|
||||
for snap in snapshots:
|
||||
ev = snap.get("total_eval", 0)
|
||||
if ev > peak:
|
||||
peak = ev
|
||||
if peak > 0:
|
||||
dd = (peak - ev) / peak * 100
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
metrics["max_drawdown_pct"] = round(max_dd, 2)
|
||||
|
||||
# Calmar Ratio
|
||||
ann_pct = annual_return * 100
|
||||
metrics["calmar_ratio"] = round(
|
||||
ann_pct / max_dd, 3) if max_dd > 0 else 0.0
|
||||
else:
|
||||
metrics["sharpe_ratio"] = 0.0
|
||||
metrics["sortino_ratio"] = 0.0
|
||||
metrics["max_drawdown_pct"] = 0.0
|
||||
metrics["calmar_ratio"] = 0.0
|
||||
|
||||
# ── 매매 지표 ─────────────────────────────
|
||||
closed = [t for t in trades
|
||||
if t.get("action") == "BUY" and t.get("outcome_return_pct") is not None]
|
||||
|
||||
if closed:
|
||||
wins = [t for t in closed if t.get("outcome_return_pct", 0) > 0]
|
||||
losses = [t for t in closed if t.get("outcome_return_pct", 0) <= 0]
|
||||
|
||||
metrics["win_rate_pct"] = round(
|
||||
len(wins) / len(closed) * 100, 1)
|
||||
|
||||
total_profit = sum(t["outcome_return_pct"] for t in wins)
|
||||
total_loss = abs(sum(t["outcome_return_pct"] for t in losses))
|
||||
metrics["profit_factor"] = round(
|
||||
total_profit / total_loss, 3) if total_loss > 0 else 10.0
|
||||
|
||||
hd_list = [t["holding_days"] for t in closed if t.get("holding_days") is not None]
|
||||
metrics["avg_holding_days"] = round(
|
||||
sum(hd_list) / len(hd_list), 1) if hd_list else 0.0
|
||||
else:
|
||||
metrics["win_rate_pct"] = 0.0
|
||||
metrics["profit_factor"] = 0.0
|
||||
metrics["avg_holding_days"] = 0.0
|
||||
|
||||
metrics["total_trades"] = len(closed)
|
||||
|
||||
# ── 벤치마크 Alpha ────────────────────────
|
||||
kospi_vals = [s.get("benchmark_kospi_close") for s in snapshots]
|
||||
kospi_valid = [k for k in kospi_vals if k is not None]
|
||||
if len(kospi_valid) >= 2:
|
||||
kospi_ret = (kospi_valid[-1] - kospi_valid[0]) / kospi_valid[0] * 100
|
||||
metrics["alpha"] = round(metrics["total_return_pct"] - kospi_ret, 2)
|
||||
metrics["kospi_return_pct"] = round(kospi_ret, 2)
|
||||
else:
|
||||
metrics["alpha"] = 0.0
|
||||
metrics["kospi_return_pct"] = 0.0
|
||||
|
||||
# ── AI 품질 지표 ──────────────────────────
|
||||
if closed:
|
||||
# LSTM 방향 정확도
|
||||
correct = 0
|
||||
direction_n = 0
|
||||
for t in closed:
|
||||
pred = t.get("ai_prediction_change")
|
||||
outcome = t.get("outcome_return_pct")
|
||||
if pred is not None and outcome is not None:
|
||||
if (pred > 0) == (outcome > 0):
|
||||
correct += 1
|
||||
direction_n += 1
|
||||
metrics["lstm_direction_accuracy"] = round(
|
||||
correct / direction_n * 100, 1) if direction_n > 0 else 0.0
|
||||
|
||||
# 신호별 수익 상관도
|
||||
outcomes = [t.get("outcome_return_pct", 0) for t in closed]
|
||||
|
||||
def pearson(xs, ys):
|
||||
n = len(xs)
|
||||
if n < 2:
|
||||
return 0.0
|
||||
mx = sum(xs) / n
|
||||
my = sum(ys) / n
|
||||
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
|
||||
denom_x = sum((x - mx) ** 2 for x in xs)
|
||||
denom_y = sum((y - my) ** 2 for y in ys)
|
||||
denom = math.sqrt(denom_x * denom_y)
|
||||
return num / denom if denom > 0 else 0.0
|
||||
|
||||
corr_tech = pearson([t.get("tech_score", 0) for t in closed], outcomes)
|
||||
corr_sent = pearson([t.get("sentiment_score", 0) for t in closed], outcomes)
|
||||
corr_lstm = pearson([t.get("lstm_score", 0) for t in closed], outcomes)
|
||||
|
||||
metrics["signal_correlation"] = {
|
||||
"tech": round(corr_tech, 3),
|
||||
"sentiment": round(corr_sent, 3),
|
||||
"lstm": round(corr_lstm, 3)
|
||||
}
|
||||
metrics["best_signal_source"] = max(
|
||||
["tech", "sentiment", "lstm"],
|
||||
key=lambda k: abs(metrics["signal_correlation"][k])
|
||||
)
|
||||
else:
|
||||
metrics["lstm_direction_accuracy"] = 0.0
|
||||
metrics["signal_correlation"] = {"tech": 0.0, "sentiment": 0.0, "lstm": 0.0}
|
||||
metrics["best_signal_source"] = "unknown"
|
||||
|
||||
metrics["snapshot_count"] = len(snapshots)
|
||||
return metrics
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 2. 등급 산출
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def get_grade(self, metric, value):
|
||||
"""지표 이름과 값으로 S/A/B/C/D/F 등급 반환."""
|
||||
# MDD는 낮을수록 좋음
|
||||
if metric == "max_drawdown_pct":
|
||||
thresholds = [(5, "S"), (10, "A"), (15, "B"), (20, "C"), (30, "D")]
|
||||
for threshold, grade in thresholds:
|
||||
if value < threshold:
|
||||
return grade
|
||||
return "F"
|
||||
|
||||
grade_rules = {
|
||||
"sharpe_ratio": [(2.0, "S"), (1.5, "A"), (1.0, "B"), (0.5, "C"), (0.0, "D")],
|
||||
"sortino_ratio": [(3.0, "S"), (2.0, "A"), (1.5, "B"), (1.0, "C"), (0.0, "D")],
|
||||
"win_rate_pct": [(70, "S"), (60, "A"), (50, "B"), (40, "C"), (30, "D")],
|
||||
"profit_factor": [(3.0, "S"), (2.0, "A"), (1.5, "B"), (1.0, "C"), (0.5, "D")],
|
||||
"alpha": [(15, "S"), (10, "A"), (5, "B"), (0, "C"), (-5, "D")],
|
||||
"total_return_pct": [(30, "S"), (20, "A"), (10, "B"), (0, "C"), (-10, "D")],
|
||||
"weekly_return_pct": [(5, "S"), (3, "A"), (1, "B"), (0, "C"), (-1, "D")],
|
||||
"lstm_direction_accuracy":[(70, "S"), (60, "A"), (55, "B"), (50, "C"), (40, "D")],
|
||||
"calmar_ratio": [(3.0, "S"), (2.0, "A"), (1.0, "B"), (0.5, "C"), (0.0, "D")],
|
||||
}
|
||||
|
||||
thresholds = grade_rules.get(metric, [])
|
||||
for threshold, grade in thresholds:
|
||||
if value >= threshold:
|
||||
return grade
|
||||
return "F"
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 3. 전문가 패널 (Ollama LLM)
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def generate_expert_panel(self, metrics):
|
||||
"""5명의 전문가 역할로 Ollama에 평가를 요청.
|
||||
|
||||
Returns:
|
||||
list[dict]: [{role, grade, comment, suggestion}, ...]
|
||||
"""
|
||||
from modules.services.ollama import OllamaManager
|
||||
ollama = OllamaManager()
|
||||
|
||||
sig_corr = metrics.get("signal_correlation", {})
|
||||
experts = [
|
||||
{
|
||||
"role": "Risk Manager",
|
||||
"focus": "risk level assessment and bankruptcy risk",
|
||||
"data": (
|
||||
f"Sharpe={metrics.get('sharpe_ratio', 0):.2f}, "
|
||||
f"Sortino={metrics.get('sortino_ratio', 0):.2f}, "
|
||||
f"MDD={metrics.get('max_drawdown_pct', 0):.1f}%, "
|
||||
f"Calmar={metrics.get('calmar_ratio', 0):.2f}"
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "Fund Manager",
|
||||
"focus": "alpha generation vs market benchmark",
|
||||
"data": (
|
||||
f"TotalReturn={metrics.get('total_return_pct', 0):.2f}%, "
|
||||
f"Alpha={metrics.get('alpha', 0):.2f}%, "
|
||||
f"KOSPI={metrics.get('kospi_return_pct', 0):.2f}%, "
|
||||
f"WeeklyReturn={metrics.get('weekly_return_pct', 0):.2f}%"
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "Quant Analyst",
|
||||
"focus": "AI model validity and signal quality",
|
||||
"data": (
|
||||
f"LSTM_Accuracy={metrics.get('lstm_direction_accuracy', 0):.1f}%, "
|
||||
f"TechCorr={sig_corr.get('tech', 0):.3f}, "
|
||||
f"SentCorr={sig_corr.get('sentiment', 0):.3f}, "
|
||||
f"LSTMCorr={sig_corr.get('lstm', 0):.3f}, "
|
||||
f"BestSignal={metrics.get('best_signal_source', 'N/A')}"
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "Trader",
|
||||
"focus": "trading strategy effectiveness",
|
||||
"data": (
|
||||
f"WinRate={metrics.get('win_rate_pct', 0):.1f}%, "
|
||||
f"ProfitFactor={metrics.get('profit_factor', 0):.2f}, "
|
||||
f"AvgHolding={metrics.get('avg_holding_days', 0):.1f}days, "
|
||||
f"TotalTrades={metrics.get('total_trades', 0)}"
|
||||
)
|
||||
},
|
||||
{
|
||||
"role": "Portfolio PM",
|
||||
"focus": "overall strategy direction and sustainability",
|
||||
"data": (
|
||||
f"WeeklyReturn={metrics.get('weekly_return_pct', 0):.2f}%, "
|
||||
f"Sharpe={metrics.get('sharpe_ratio', 0):.2f}, "
|
||||
f"WinRate={metrics.get('win_rate_pct', 0):.1f}%, "
|
||||
f"Alpha={metrics.get('alpha', 0):.2f}%, "
|
||||
f"MDD={metrics.get('max_drawdown_pct', 0):.1f}%"
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
results = []
|
||||
for exp in experts:
|
||||
prompt = (
|
||||
f"You are a professional {exp['role']} evaluating an AI stock trading bot. "
|
||||
f"Your focus: {exp['focus']}. "
|
||||
f"Performance data: {exp['data']}. "
|
||||
f"Respond ONLY with valid JSON (no markdown, no extra text): "
|
||||
f"{{\"grade\":\"S|A|B|C|D|F\","
|
||||
f"\"comment\":\"1 sentence evaluation in Korean\","
|
||||
f"\"suggestion\":\"1 sentence improvement tip in Korean\"}}"
|
||||
)
|
||||
try:
|
||||
resp = ollama.request_inference(prompt)
|
||||
if not resp:
|
||||
raise ValueError("Empty response from Ollama")
|
||||
data = json.loads(resp)
|
||||
results.append({
|
||||
"role": exp["role"],
|
||||
"grade": data.get("grade", "C"),
|
||||
"comment": data.get("comment", "(응답 없음)"),
|
||||
"suggestion": data.get("suggestion", "데이터 축적 필요")
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[Evaluator] Expert panel [{exp['role']}] error: {e}")
|
||||
results.append({
|
||||
"role": exp["role"],
|
||||
"grade": "C",
|
||||
"comment": "평가 데이터가 부족합니다.",
|
||||
"suggestion": "더 많은 거래 데이터 축적 후 재평가를 권장합니다."
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 4. 주간 보고서 생성
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def generate_weekly_report(self):
|
||||
"""주간 성과 보고서 (텔레그램 HTML 형식) 반환."""
|
||||
snapshots = self.perf_db.load_snapshots(days=7)
|
||||
# 매매 완료 건은 30일치 사용 (주간 거래 수가 적을 수 있음)
|
||||
trades = self.perf_db.load_trades(days=30)
|
||||
|
||||
metrics = self.compute_metrics(snapshots, trades)
|
||||
|
||||
if "error" in metrics:
|
||||
return (
|
||||
f"<b>[주간 성과 평가 보고서]</b>\n"
|
||||
f"⚠️ {metrics['error']}\n"
|
||||
f"<i>매일 오전 09:05~09:15에 스냅샷이 저장됩니다.</i>"
|
||||
)
|
||||
|
||||
# 등급 계산
|
||||
g_sharpe = self.get_grade("sharpe_ratio", metrics.get("sharpe_ratio", 0))
|
||||
g_win = self.get_grade("win_rate_pct", metrics.get("win_rate_pct", 0))
|
||||
g_mdd = self.get_grade("max_drawdown_pct", metrics.get("max_drawdown_pct", 0))
|
||||
g_alpha = self.get_grade("alpha", metrics.get("alpha", 0))
|
||||
g_weekly = self.get_grade("weekly_return_pct", metrics.get("weekly_return_pct", 0))
|
||||
g_lstm = self.get_grade("lstm_direction_accuracy",
|
||||
metrics.get("lstm_direction_accuracy", 0))
|
||||
|
||||
# 종합 등급 (Sharpe/Win/MDD/Alpha 평균)
|
||||
grade_map = {"S": 5, "A": 4, "B": 3, "C": 2, "D": 1, "F": 0}
|
||||
grade_rev = {v: k for k, v in grade_map.items()}
|
||||
key_grades = [grade_map[g] for g in [g_sharpe, g_win, g_mdd, g_alpha]]
|
||||
overall_grade = grade_rev[round(sum(key_grades) / len(key_grades))]
|
||||
|
||||
# 전문가 패널 (Ollama 호출)
|
||||
try:
|
||||
experts = self.generate_expert_panel(metrics)
|
||||
except Exception as e:
|
||||
print(f"[Evaluator] Expert panel skipped: {e}")
|
||||
experts = []
|
||||
|
||||
now_str = datetime.now().strftime("%Y/%m/%d %H:%M")
|
||||
corr = metrics.get("signal_correlation", {})
|
||||
|
||||
report = (
|
||||
f"📊 <b>[주간 성과 평가 보고서]</b> <code>{now_str}</code>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"\n<b>■ 수익률</b>\n"
|
||||
f" 주간: <code>{metrics.get('weekly_return_pct', 0):+.2f}%</code> [{g_weekly}]"
|
||||
f" 누적: <code>{metrics.get('total_return_pct', 0):+.2f}%</code>\n"
|
||||
f" Alpha: <code>{metrics.get('alpha', 0):+.2f}%</code> [{g_alpha}]"
|
||||
f" vs KOSPI <code>{metrics.get('kospi_return_pct', 0):+.2f}%</code>\n"
|
||||
f"\n<b>■ 리스크</b>\n"
|
||||
f" Sharpe: <code>{metrics.get('sharpe_ratio', 0):.2f}</code> [{g_sharpe}]"
|
||||
f" Sortino: <code>{metrics.get('sortino_ratio', 0):.2f}</code>\n"
|
||||
f" MDD: <code>{metrics.get('max_drawdown_pct', 0):.1f}%</code> [{g_mdd}]"
|
||||
f" Calmar: <code>{metrics.get('calmar_ratio', 0):.2f}</code>\n"
|
||||
f"\n<b>■ 매매 통계</b>\n"
|
||||
f" 승률: <code>{metrics.get('win_rate_pct', 0):.1f}%</code> [{g_win}]"
|
||||
f" PF: <code>{metrics.get('profit_factor', 0):.2f}</code>\n"
|
||||
f" 평균보유: <code>{metrics.get('avg_holding_days', 0):.1f}일</code>"
|
||||
f" 완료매매: <code>{metrics.get('total_trades', 0)}건</code>\n"
|
||||
f"\n<b>■ AI 품질</b>\n"
|
||||
f" LSTM 방향정확도: <code>{metrics.get('lstm_direction_accuracy', 0):.1f}%</code>"
|
||||
f" [{g_lstm}]\n"
|
||||
f" 신호 상관도 — Tech: <code>{corr.get('tech', 0):.3f}</code>"
|
||||
f" Sent: <code>{corr.get('sentiment', 0):.3f}</code>"
|
||||
f" LSTM: <code>{corr.get('lstm', 0):.3f}</code>\n"
|
||||
f" 최고기여 신호: <code>{metrics.get('best_signal_source', 'N/A')}</code>\n"
|
||||
)
|
||||
|
||||
if experts:
|
||||
role_icons = {
|
||||
"Risk Manager": "🛡",
|
||||
"Fund Manager": "💼",
|
||||
"Quant Analyst": "🧮",
|
||||
"Trader": "📈",
|
||||
"Portfolio PM": "🏦"
|
||||
}
|
||||
report += "\n<b>■ 전문가 패널 의견</b>\n"
|
||||
for exp in experts:
|
||||
icon = role_icons.get(exp["role"], "👤")
|
||||
report += (
|
||||
f"{icon} <b>{exp['role']}</b> [{exp['grade']}]\n"
|
||||
f" {exp['comment']}\n"
|
||||
f" 💡 {exp['suggestion']}\n"
|
||||
)
|
||||
|
||||
report += (
|
||||
f"\n━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"🏆 <b>종합 등급: [{overall_grade}]</b>\n"
|
||||
f"<i>스냅샷 {metrics.get('snapshot_count', 0)}일 | 완료매매 {metrics.get('total_trades', 0)}건 기준</i>"
|
||||
)
|
||||
|
||||
return report
|
||||
157
signal_v1/modules/analysis/macro.py
Normal file
157
signal_v1/modules/analysis/macro.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from datetime import datetime
|
||||
import time
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from modules.services.kis import KISClient
|
||||
|
||||
class MacroAnalyzer:
|
||||
"""
|
||||
KIS API를 활용한 거시경제(시장 지수) 분석 모듈
|
||||
yfinance 대신 한국투자증권 API를 사용하여 안정적인 KOSPI, KOSDAQ 데이터를 수집함.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_macro_status(kis_client):
|
||||
"""
|
||||
시장 주요 지수(KOSPI, KOSDAQ)를 조회하여 시장 위험도를 평가함.
|
||||
Args:
|
||||
kis_client (KISClient): 인증된 KIS API 클라이언트 인스턴스
|
||||
|
||||
Returns:
|
||||
dict: 시장 상태 (SAFE, CAUTION, DANGER) 및 지표 데이터
|
||||
"""
|
||||
indicators = {
|
||||
"KOSPI": "0001",
|
||||
"KOSDAQ": "1001",
|
||||
"KOSPI200": "0028",
|
||||
}
|
||||
|
||||
results = {}
|
||||
risk_score = 0
|
||||
|
||||
print("🌍 [Macro] Fetching market indices via KIS API...")
|
||||
|
||||
for name, code in indicators.items():
|
||||
data = kis_client.get_current_index(code)
|
||||
time.sleep(0.6) # Rate Limit 방지 (초당 2회 제한)
|
||||
|
||||
if data and data.get('price', 0) != 0:
|
||||
results[name] = data
|
||||
print(f" - {name}: {data['price']} ({data['change']}%)")
|
||||
|
||||
# 리스크 평가 로직 (2% 이상 폭락 장이면 위험)
|
||||
change = data['change']
|
||||
if change <= -2.0:
|
||||
risk_score += 2 # 패닉 상태
|
||||
elif change <= -1.0:
|
||||
risk_score += 1 # 주의 상태
|
||||
else:
|
||||
results[name] = {"price": 0, "change": 0, "high": 0, "low": 0,
|
||||
"prev_close": 0, "volume": 0, "trade_value": 0}
|
||||
|
||||
# [신규] 시장 스트레스 지수(MSI) 추가
|
||||
time.sleep(0.6)
|
||||
kospi_stress = MacroAnalyzer.calculate_stress_index(kis_client, "0001")
|
||||
results['MSI'] = kospi_stress
|
||||
print(f" - Market Stress Index: {kospi_stress}")
|
||||
|
||||
if kospi_stress >= 50:
|
||||
risk_score += 2
|
||||
elif kospi_stress >= 30:
|
||||
risk_score += 1
|
||||
|
||||
# [v2.0] KOSPI/KOSDAQ 연동 위험도 (둘 다 하락 시 더 위험)
|
||||
kospi_change = results.get('KOSPI', {}).get('change', 0)
|
||||
kosdaq_change = results.get('KOSDAQ', {}).get('change', 0)
|
||||
if kospi_change <= -1.0 and kosdaq_change <= -1.0:
|
||||
risk_score += 1 # 양대 지수 동반 하락
|
||||
print(f" ⚠️ Both KOSPI({kospi_change}%) & KOSDAQ({kosdaq_change}%) declining!")
|
||||
|
||||
# [v2.0] 급반등 감지 (전일 급락 후 반등 = 불안정)
|
||||
if kospi_change >= 2.0 and kospi_stress >= 30:
|
||||
risk_score = max(risk_score, 1) # 급반등이지만 스트레스 높으면 CAUTION 유지
|
||||
print(f" 📈 Sharp rebound detected but MSI still elevated")
|
||||
|
||||
# 시장 상태 정의
|
||||
status = "SAFE"
|
||||
if risk_score >= 3:
|
||||
status = "DANGER"
|
||||
elif risk_score >= 1:
|
||||
status = "CAUTION"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"risk_score": risk_score,
|
||||
"indicators": results
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def calculate_stress_index(kis_client, market_code="0001"):
|
||||
"""
|
||||
시장 스트레스 지수(MSI) 계산
|
||||
- 0~100 사이의 값 (높을수록 위험)
|
||||
- 요소: 변동성(Volatility), 추세 이격도(MA Divergence)
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# 일봉 데이터 조회 (약 3개월치 = 60일 이상)
|
||||
prices = kis_client.get_daily_index_price(market_code, period="D")
|
||||
if not prices or len(prices) < 20:
|
||||
return 0
|
||||
|
||||
prices = np.array(prices)
|
||||
|
||||
# 1. 역사적 변동성 (20일)
|
||||
# 로그 수익률 계산
|
||||
returns = np.diff(np.log(prices))
|
||||
# 연환산 변동성 (Trading days = 252)
|
||||
volatility = np.std(returns[-20:]) * np.sqrt(252) * 100
|
||||
|
||||
# 2. 이동평균 이격도
|
||||
ma20 = np.mean(prices[-20:])
|
||||
current_price = prices[-1]
|
||||
disparity = (current_price - ma20) / ma20 * 100
|
||||
|
||||
# 3. 스트레스 점수 산출
|
||||
# 변동성이 20% 넘어가면 위험, 이격도가 -5% 이하면 위험
|
||||
stress_score = 0
|
||||
|
||||
# 변동성 기여 (평소 10~15%, 30% 이상 공포)
|
||||
# 10 이하면 0점, 40 이상이면 60점 만점
|
||||
v_score = min(max((volatility - 10) * 2, 0), 60)
|
||||
|
||||
# 하락 추세 기여 (-10% 이격이면 +40점)
|
||||
d_score = 0
|
||||
if disparity < 0:
|
||||
d_score = min(abs(disparity) * 4, 40)
|
||||
|
||||
total_stress = v_score + d_score
|
||||
return round(total_stress, 2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트를 위한 코드
|
||||
load_dotenv(Path(__file__).parent.parent.parent / ".env")
|
||||
|
||||
# 환경변수 로딩 및 클라이언트 초기화
|
||||
if os.getenv("KIS_ENV_TYPE") == "real":
|
||||
app_key = os.getenv("KIS_REAL_APP_KEY")
|
||||
app_secret = os.getenv("KIS_REAL_APP_SECRET")
|
||||
account = os.getenv("KIS_REAL_ACCOUNT")
|
||||
is_virtual = False
|
||||
else:
|
||||
app_key = os.getenv("KIS_VIRTUAL_APP_KEY")
|
||||
app_secret = os.getenv("KIS_VIRTUAL_APP_SECRET")
|
||||
account = os.getenv("KIS_VIRTUAL_ACCOUNT")
|
||||
is_virtual = True
|
||||
|
||||
kis = KISClient(app_key, app_secret, account, is_virtual)
|
||||
|
||||
# 토큰 발급 (필요 시)
|
||||
kis.ensure_token()
|
||||
|
||||
# 분석 실행
|
||||
report = MacroAnalyzer.get_macro_status(kis)
|
||||
print("\n📊 [Macro Report]")
|
||||
print(f"Status: {report['status']}")
|
||||
print(f"Data: {report['indicators']}")
|
||||
279
signal_v1/modules/analysis/market_regime.py
Normal file
279
signal_v1/modules/analysis/market_regime.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
시장 레짐 감지 모듈
|
||||
- 코스피 지수 수준에 따른 시장 레짐 분류
|
||||
- 코스피 6300 목표 수준에서의 모델 적합성 평가
|
||||
- 레짐별 전략 파라미터 자동 조정
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class MarketRegime(Enum):
|
||||
BULL_EXTREME = "bull_extreme" # 코스피 5000+ (역사적 극고점, 6300 시나리오)
|
||||
BULL_STRONG = "bull_strong" # 코스피 3500~5000 (강한 상승장)
|
||||
BULL_NORMAL = "bull_normal" # 코스피 2500~3500 (정상 상승장)
|
||||
SIDEWAYS = "sideways" # 코스피 2000~2500 (횡보)
|
||||
BEAR_MILD = "bear_mild" # 코스피 1500~2000 (약세)
|
||||
BEAR_SEVERE = "bear_severe" # 코스피 1500 미만 (심각한 약세)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegimeAnalysis:
|
||||
"""레짐 분석 결과"""
|
||||
regime: MarketRegime
|
||||
kospi_level: float
|
||||
description: str
|
||||
recommended_strategy: str
|
||||
buy_threshold_adj: float # 매수 임계값 조정치 (+: 더 엄격, -: 완화)
|
||||
position_size_adj: float # 포지션 크기 조정 배수 (1.0 = 기본)
|
||||
lstm_weight_adj: float # LSTM 앙상블 가중치 조정 (+0.1 = 10% 증가)
|
||||
model_recommendation: str # 모델 유지/교체 권고
|
||||
risk_level: str # LOW / MEDIUM / HIGH / EXTREME
|
||||
|
||||
|
||||
class MarketRegimeDetector:
|
||||
"""
|
||||
코스피 지수 수준 기반 시장 레짐 감지기
|
||||
|
||||
코스피 6300 시나리오:
|
||||
- 현재 한국 증시 역대 최고점(2021년 3300) 대비 약 2배 수준
|
||||
- BULL_EXTREME 레짐에 해당 → LSTM 단독 의존 지양, Transformer/Mamba 검토 필요
|
||||
- 추세 추종 강화 + 고점 리스크 관리 병행
|
||||
"""
|
||||
|
||||
# 레짐별 상세 파라미터
|
||||
_REGIME_PARAMS: Dict[MarketRegime, dict] = {
|
||||
MarketRegime.BULL_EXTREME: {
|
||||
"description": "코스피 극강세장 5000+ (6300 시나리오)",
|
||||
"recommended_strategy": (
|
||||
"추세 추종 극대화, 트레일링 스탑 확대(ATR×4), "
|
||||
"고점 과열 구간으로 포지션 축소 병행"
|
||||
),
|
||||
"buy_threshold_adj": -0.04, # 강세 모멘텀 → 진입 소폭 완화
|
||||
"position_size_adj": 0.75, # 고점 리스크로 포지션 축소
|
||||
"lstm_weight_adj": -0.12, # LSTM 비중 축소 (비선형 가격 동작)
|
||||
"model_recommendation": (
|
||||
"Temporal Fusion Transformer(TFT) 또는 Mamba(SSM) 교체 권고 - "
|
||||
"LSTM은 극강세 과열 구간에서 비선형 가격 동작 포착 한계"
|
||||
),
|
||||
"risk_level": "EXTREME",
|
||||
},
|
||||
MarketRegime.BULL_STRONG: {
|
||||
"description": "코스피 강상승장 3500~5000",
|
||||
"recommended_strategy": "추세 추종, 모멘텀 강화, 손절 완화(ATR×2.5)",
|
||||
"buy_threshold_adj": -0.03,
|
||||
"position_size_adj": 1.1,
|
||||
"lstm_weight_adj": 0.05,
|
||||
"model_recommendation": "현재 LSTM v3 적합 - 성능 모니터링 유지",
|
||||
"risk_level": "MEDIUM",
|
||||
},
|
||||
MarketRegime.BULL_NORMAL: {
|
||||
"description": "코스피 정상 상승장 2500~3500",
|
||||
"recommended_strategy": "기본 전략 유지 (기술+LSTM+LLM 균형)",
|
||||
"buy_threshold_adj": 0.0,
|
||||
"position_size_adj": 1.0,
|
||||
"lstm_weight_adj": 0.0,
|
||||
"model_recommendation": "현재 LSTM v3 최적 환경",
|
||||
"risk_level": "LOW",
|
||||
},
|
||||
MarketRegime.SIDEWAYS: {
|
||||
"description": "코스피 횡보장 2000~2500",
|
||||
"recommended_strategy": "박스권 매매, LLM 감성 비중 확대, 빠른 익절",
|
||||
"buy_threshold_adj": 0.03,
|
||||
"position_size_adj": 0.85,
|
||||
"lstm_weight_adj": -0.05,
|
||||
"model_recommendation": "현재 LSTM v3 적합 - 감성 분석 가중치 강화",
|
||||
"risk_level": "LOW",
|
||||
},
|
||||
MarketRegime.BEAR_MILD: {
|
||||
"description": "코스피 약세장 1500~2000",
|
||||
"recommended_strategy": "현금 비중 확대(50%+), 방어주 선별 매수",
|
||||
"buy_threshold_adj": 0.08,
|
||||
"position_size_adj": 0.5,
|
||||
"lstm_weight_adj": 0.0,
|
||||
"model_recommendation": "현재 LSTM v3 적합 - 리스크 관리 파라미터 강화",
|
||||
"risk_level": "HIGH",
|
||||
},
|
||||
MarketRegime.BEAR_SEVERE: {
|
||||
"description": "코스피 극약세장 1500 미만",
|
||||
"recommended_strategy": "전면 현금화, 매수 중단",
|
||||
"buy_threshold_adj": 0.20,
|
||||
"position_size_adj": 0.2,
|
||||
"lstm_weight_adj": 0.0,
|
||||
"model_recommendation": "매크로 팩터 기반 방어 모델 전환 필요",
|
||||
"risk_level": "EXTREME",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def detect(
|
||||
cls,
|
||||
kospi_price: float,
|
||||
kospi_change_pct: float = 0.0,
|
||||
volatility_20d: float = 0.0,
|
||||
) -> RegimeAnalysis:
|
||||
"""
|
||||
코스피 지수 수준 + 변동성으로 시장 레짐 감지
|
||||
|
||||
Args:
|
||||
kospi_price: 현재 코스피 지수 (예: 2600, 6300)
|
||||
kospi_change_pct: 전일 대비 등락률 (%)
|
||||
volatility_20d: 20일 변동성 (선택, 0이면 무시)
|
||||
|
||||
Returns:
|
||||
RegimeAnalysis: 레짐 분석 결과 및 전략 파라미터
|
||||
"""
|
||||
# 1. 지수 수준으로 기본 레짐 결정
|
||||
if kospi_price >= 5000:
|
||||
regime = MarketRegime.BULL_EXTREME
|
||||
elif kospi_price >= 3500:
|
||||
regime = MarketRegime.BULL_STRONG
|
||||
elif kospi_price >= 2500:
|
||||
regime = MarketRegime.BULL_NORMAL
|
||||
elif kospi_price >= 2000:
|
||||
regime = MarketRegime.SIDEWAYS
|
||||
elif kospi_price >= 1500:
|
||||
regime = MarketRegime.BEAR_MILD
|
||||
else:
|
||||
regime = MarketRegime.BEAR_SEVERE
|
||||
|
||||
params = cls._REGIME_PARAMS[regime]
|
||||
|
||||
# 2. 변동성 기반 포지션 사이징 추가 조정
|
||||
position_adj = params["position_size_adj"]
|
||||
if volatility_20d > 30:
|
||||
position_adj *= 0.6 # 극단적 변동성 → 추가 50% 축소
|
||||
elif volatility_20d > 20:
|
||||
position_adj *= 0.8 # 높은 변동성 → 20% 축소
|
||||
|
||||
# 3. 급락 중 레짐 하향 조정 (패닉 감지)
|
||||
if kospi_change_pct <= -3.0:
|
||||
# 극단적 일일 급락 → 포지션 추가 축소
|
||||
position_adj *= 0.5
|
||||
print(f"[Regime] PANIC DETECTED (일일 {kospi_change_pct:.1f}%) → 포지션 50% 추가 축소")
|
||||
|
||||
return RegimeAnalysis(
|
||||
regime=regime,
|
||||
kospi_level=kospi_price,
|
||||
description=params["description"],
|
||||
recommended_strategy=params["recommended_strategy"],
|
||||
buy_threshold_adj=params["buy_threshold_adj"],
|
||||
position_size_adj=round(position_adj, 3),
|
||||
lstm_weight_adj=params["lstm_weight_adj"],
|
||||
model_recommendation=params["model_recommendation"],
|
||||
risk_level=params["risk_level"],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_model_for_regime(
|
||||
cls,
|
||||
regime: MarketRegime,
|
||||
backtest_sharpe: Optional[float] = None,
|
||||
backtest_winrate: Optional[float] = None,
|
||||
backtest_mdd: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
현재 LSTM v3 모델이 해당 레짐에서 적합한지 검증
|
||||
|
||||
Returns:
|
||||
{
|
||||
"is_suitable": bool,
|
||||
"confidence_score": float (0~1),
|
||||
"recommendation": str,
|
||||
"should_replace": bool,
|
||||
"alternative_models": list[str],
|
||||
"reason": str,
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"is_suitable": True,
|
||||
"confidence_score": 0.75,
|
||||
"recommendation": "현재 LSTM v3 모델 유지",
|
||||
"should_replace": False,
|
||||
"alternative_models": [],
|
||||
"reason": "정상 상승장 구간 - LSTM v3 최적 환경",
|
||||
}
|
||||
|
||||
# 레짐 기반 기본 평가
|
||||
if regime == MarketRegime.BULL_EXTREME:
|
||||
result.update({
|
||||
"is_suitable": False,
|
||||
"confidence_score": 0.38,
|
||||
"recommendation": "Transformer 계열 모델 교체 강력 권고",
|
||||
"should_replace": True,
|
||||
"alternative_models": [
|
||||
"Temporal Fusion Transformer (TFT) - 장기 시계열 최강",
|
||||
"Mamba (SSM) - 초고속 추론 + 긴 컨텍스트",
|
||||
"PatchTST - Transformer 기반 주가 예측 특화",
|
||||
"TimesNet - 2D 시계열 변환 + CNN",
|
||||
"N-BEATS / N-HiTS - 해석 가능 딥러닝",
|
||||
],
|
||||
"reason": (
|
||||
"코스피 5000+ 극강세장에서 LSTM은 비선형적 가격 급등 패턴을 "
|
||||
"충분히 학습하지 못함. Attention 메커니즘만으로는 장기 상승 추세의 "
|
||||
"복잡한 의존성 포착에 한계 존재."
|
||||
),
|
||||
})
|
||||
elif regime == MarketRegime.BEAR_SEVERE:
|
||||
result.update({
|
||||
"is_suitable": False,
|
||||
"confidence_score": 0.30,
|
||||
"recommendation": "매크로 팩터 + Regime-Switching 모델 교체 권고",
|
||||
"should_replace": True,
|
||||
"alternative_models": [
|
||||
"Regime-Switching LSTM (HMM + LSTM)",
|
||||
"매크로 멀티팩터 모델 (환율, 금리, VIX 통합)",
|
||||
"GRU + Attention (LSTM 경량 대안)",
|
||||
],
|
||||
"reason": "극약세장에서는 기술적 지표보다 거시경제 팩터가 지배적",
|
||||
})
|
||||
elif regime == MarketRegime.BULL_STRONG:
|
||||
result.update({
|
||||
"confidence_score": 0.72,
|
||||
"reason": "강상승장 - LSTM 추세 학습 양호하나 성능 모니터링 필요",
|
||||
})
|
||||
elif regime == MarketRegime.SIDEWAYS:
|
||||
result.update({
|
||||
"confidence_score": 0.68,
|
||||
"reason": "횡보장 - LSTM 예측력 저하, LLM 감성 보완 필수",
|
||||
"recommendation": "현재 LSTM v3 유지 + LLM 감성 가중치 상향",
|
||||
})
|
||||
|
||||
# 백테스트 결과 반영
|
||||
if backtest_sharpe is not None:
|
||||
if backtest_sharpe < 0:
|
||||
result["confidence_score"] *= 0.5
|
||||
result["should_replace"] = True
|
||||
result["recommendation"] += " ⚠️ Sharpe < 0 → 즉시 교체 검토"
|
||||
elif backtest_sharpe < 0.5:
|
||||
result["confidence_score"] *= 0.75
|
||||
result["recommendation"] += f" (Sharpe={backtest_sharpe:.2f} 미흡)"
|
||||
|
||||
if backtest_winrate is not None and backtest_winrate < 45:
|
||||
result["confidence_score"] *= 0.8
|
||||
result["recommendation"] += f" (승률={backtest_winrate:.1f}% 미흡)"
|
||||
|
||||
if backtest_mdd is not None and backtest_mdd < -25:
|
||||
result["confidence_score"] *= 0.7
|
||||
result["should_replace"] = True
|
||||
result["recommendation"] += f" ⚠️ MDD={backtest_mdd:.1f}% 과다"
|
||||
|
||||
result["confidence_score"] = round(max(0.0, min(1.0, result["confidence_score"])), 3)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_regime_label(kospi_price: float) -> str:
|
||||
"""간략 레짐 라벨 반환 (로그/UI 표시용)"""
|
||||
if kospi_price >= 5000:
|
||||
return f"BULL_EXTREME({kospi_price:.0f})"
|
||||
elif kospi_price >= 3500:
|
||||
return f"BULL_STRONG({kospi_price:.0f})"
|
||||
elif kospi_price >= 2500:
|
||||
return f"BULL_NORMAL({kospi_price:.0f})"
|
||||
elif kospi_price >= 2000:
|
||||
return f"SIDEWAYS({kospi_price:.0f})"
|
||||
elif kospi_price >= 1500:
|
||||
return f"BEAR_MILD({kospi_price:.0f})"
|
||||
return f"BEAR_SEVERE({kospi_price:.0f})"
|
||||
348
signal_v1/modules/analysis/model_validator.py
Normal file
348
signal_v1/modules/analysis/model_validator.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
모델 검증 시스템 (Market-Regime Aware Model Validator)
|
||||
- 백테스트 기반 현재 LSTM v3 성능 검증
|
||||
- 코스피 레짐별 모델 적합성 평가
|
||||
- 코스피 6300 강세장 시나리오 대응 점검
|
||||
- 모델 교체 권고 보고서 생성
|
||||
|
||||
사용법:
|
||||
validator = ModelValidator()
|
||||
report = validator.validate(ticker, ohlcv_data, strategy_fn, kospi_price=2600)
|
||||
print(report.summary())
|
||||
validator.send_alert(report) # 텔레그램 알림 (심각한 경우만)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List
|
||||
|
||||
from modules.config import Config
|
||||
from modules.analysis.backtest import Backtester, BacktestResult
|
||||
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
|
||||
|
||||
|
||||
# 모델 적합성 최소 기준
|
||||
_MIN_SHARPE = 0.5
|
||||
_MIN_WIN_RATE = 50.0 # %
|
||||
_MAX_MDD = -20.0 # % (초과 시 문제)
|
||||
_MIN_PROFIT_FACTOR = 1.2
|
||||
_CACHE_TTL_SECONDS = 86400 # 24시간
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
"""모델 검증 보고서"""
|
||||
ticker: str
|
||||
kospi_level: float
|
||||
regime: str
|
||||
regime_description: str
|
||||
backtest_result: Optional[BacktestResult]
|
||||
model_suitable: bool
|
||||
suitability_score: float # 0~1
|
||||
issues: List[str] = field(default_factory=list)
|
||||
recommendations: List[str] = field(default_factory=list)
|
||||
alternative_models: List[str] = field(default_factory=list)
|
||||
regime_strategy_hint: str = ""
|
||||
risk_level: str = "LOW"
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
"=" * 55,
|
||||
f"🔍 모델 검증 보고서 [{self.ticker}]",
|
||||
"=" * 55,
|
||||
f"코스피 수준 : {self.kospi_level:.0f} ({self.regime_description})",
|
||||
f"시장 레짐 : {self.regime} [리스크: {self.risk_level}]",
|
||||
f"모델 적합성 : {'✅ 적합' if self.model_suitable else '⚠️ 부적합'} "
|
||||
f"({self.suitability_score:.0%})",
|
||||
]
|
||||
|
||||
if self.backtest_result:
|
||||
bt = self.backtest_result
|
||||
lines += [
|
||||
"",
|
||||
"📊 백테스트 성과",
|
||||
f" 총 수익률 : {bt.total_return_pct:+.2f}%",
|
||||
f" Sharpe Ratio : {bt.sharpe_ratio:.3f}",
|
||||
f" Max Drawdown : {bt.max_drawdown_pct:.2f}%",
|
||||
f" 승률 : {bt.win_rate:.1f}% ({bt.winning_trades}/{bt.total_trades})",
|
||||
f" 손익비(PF) : {bt.profit_factor:.2f}",
|
||||
]
|
||||
|
||||
if self.issues:
|
||||
lines.append("")
|
||||
lines.append(f"⚠️ 발견된 문제 ({len(self.issues)}건)")
|
||||
for issue in self.issues:
|
||||
lines.append(f" - {issue}")
|
||||
|
||||
if self.recommendations:
|
||||
lines.append("")
|
||||
lines.append("💡 권고사항")
|
||||
for rec in self.recommendations:
|
||||
lines.append(f" → {rec}")
|
||||
|
||||
if self.alternative_models:
|
||||
lines.append("")
|
||||
lines.append("🔄 대안 모델 목록")
|
||||
for model in self.alternative_models:
|
||||
lines.append(f" • {model}")
|
||||
|
||||
if self.regime_strategy_hint:
|
||||
lines.append("")
|
||||
lines.append(f"📌 레짐 전략: {self.regime_strategy_hint}")
|
||||
|
||||
lines.append("=" * 55)
|
||||
return "\n".join(lines)
|
||||
|
||||
def is_critical(self) -> bool:
|
||||
"""즉각적인 조치가 필요한 수준인지 (텔레그램 알림 기준)"""
|
||||
if not self.model_suitable and self.suitability_score < 0.4:
|
||||
return True
|
||||
if self.backtest_result and self.backtest_result.sharpe_ratio < 0:
|
||||
return True
|
||||
if self.backtest_result and self.backtest_result.max_drawdown_pct < -30:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ModelValidator:
|
||||
"""
|
||||
LSTM v3 모델 검증기
|
||||
|
||||
검증 흐름:
|
||||
1. 시장 레짐 감지 (코스피 수준)
|
||||
2. 백테스트 실행 (선택)
|
||||
3. 레짐별 모델 적합성 평가
|
||||
4. 종합 보고서 생성
|
||||
5. 심각한 경우 텔레그램 알림
|
||||
"""
|
||||
|
||||
_CACHE_FILE = "model_validation_cache.json"
|
||||
|
||||
def __init__(self):
|
||||
self._cache_path = os.path.join(Config.DATA_DIR, self._CACHE_FILE)
|
||||
self._cache: dict = self._load_cache()
|
||||
|
||||
def _load_cache(self) -> dict:
|
||||
if os.path.exists(self._cache_path):
|
||||
try:
|
||||
with open(self._cache_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_cache(self):
|
||||
try:
|
||||
with open(self._cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Validator] 캐시 저장 실패: {e}")
|
||||
|
||||
def validate(
|
||||
self,
|
||||
ticker: str,
|
||||
ohlcv_data: dict,
|
||||
strategy_fn=None,
|
||||
kospi_price: float = 2500.0,
|
||||
kospi_change_pct: float = 0.0,
|
||||
run_backtest: bool = True,
|
||||
) -> ValidationReport:
|
||||
"""
|
||||
모델 검증 실행
|
||||
|
||||
Args:
|
||||
ticker: 종목 코드
|
||||
ohlcv_data: OHLCV 딕셔너리
|
||||
strategy_fn: 백테스트용 전략 함수 (None이면 백테스트 생략)
|
||||
kospi_price: 현재 코스피 지수
|
||||
kospi_change_pct: 코스피 당일 등락률
|
||||
run_backtest: 백테스트 실행 여부
|
||||
|
||||
Returns:
|
||||
ValidationReport
|
||||
"""
|
||||
issues: List[str] = []
|
||||
recommendations: List[str] = []
|
||||
|
||||
# ── 1. 시장 레짐 감지 ────────────────────────────────
|
||||
regime_analysis: RegimeAnalysis = MarketRegimeDetector.detect(
|
||||
kospi_price, kospi_change_pct
|
||||
)
|
||||
|
||||
# ── 2. 백테스트 (선택) ───────────────────────────────
|
||||
backtest_result: Optional[BacktestResult] = None
|
||||
if run_backtest and strategy_fn is not None:
|
||||
try:
|
||||
backtester = Backtester()
|
||||
backtest_result = backtester.run(ohlcv_data, strategy_fn, ticker)
|
||||
except Exception as e:
|
||||
issues.append(f"백테스트 실행 오류: {e}")
|
||||
|
||||
# ── 3. 백테스트 결과 기준 위반 체크 ─────────────────
|
||||
bt_sharpe = backtest_result.sharpe_ratio if backtest_result else None
|
||||
bt_winrate = backtest_result.win_rate if backtest_result else None
|
||||
bt_mdd = backtest_result.max_drawdown_pct if backtest_result else None
|
||||
bt_pf = backtest_result.profit_factor if backtest_result else None
|
||||
|
||||
if backtest_result:
|
||||
if bt_sharpe < _MIN_SHARPE:
|
||||
issues.append(
|
||||
f"Sharpe Ratio 미흡: {bt_sharpe:.3f} (최소 {_MIN_SHARPE})"
|
||||
)
|
||||
recommendations.append("LSTM 피처 확장 또는 모델 아키텍처 재검토")
|
||||
if bt_winrate < _MIN_WIN_RATE:
|
||||
issues.append(
|
||||
f"승률 미흡: {bt_winrate:.1f}% (최소 {_MIN_WIN_RATE:.0f}%)"
|
||||
)
|
||||
recommendations.append("매수 진입 임계값 상향 조정 (+0.05)")
|
||||
if bt_mdd < _MAX_MDD:
|
||||
issues.append(
|
||||
f"MDD 과다: {bt_mdd:.2f}% (허용 {_MAX_MDD:.0f}%)"
|
||||
)
|
||||
recommendations.append("ATR 손절 배수 축소 (ATR×2 → ATR×1.5)")
|
||||
if bt_pf < _MIN_PROFIT_FACTOR:
|
||||
issues.append(
|
||||
f"손익비 미흡: {bt_pf:.2f} (최소 {_MIN_PROFIT_FACTOR})"
|
||||
)
|
||||
recommendations.append("익절 배수 확대 (ATR×3 → ATR×4)")
|
||||
|
||||
# ── 4. 레짐 기반 모델 적합성 평가 ───────────────────
|
||||
regime_validation = MarketRegimeDetector.validate_model_for_regime(
|
||||
regime_analysis.regime,
|
||||
backtest_sharpe=bt_sharpe,
|
||||
backtest_winrate=bt_winrate,
|
||||
backtest_mdd=bt_mdd,
|
||||
)
|
||||
|
||||
if not regime_validation["is_suitable"]:
|
||||
issues.append(
|
||||
f"레짐 부적합: {regime_analysis.regime.value} 환경에서 "
|
||||
f"LSTM v3 한계 감지"
|
||||
)
|
||||
recommendations.append(regime_validation["recommendation"])
|
||||
|
||||
# 코스피 6300 특별 경고
|
||||
if kospi_price >= 5000:
|
||||
issues.append(
|
||||
f"⚠️ 코스피 {kospi_price:.0f} - 역사적 극고점 수준 "
|
||||
"LSTM 비선형 패턴 포착 한계 주의"
|
||||
)
|
||||
recommendations.append(
|
||||
"Temporal Fusion Transformer(TFT) 또는 Mamba 모델 전환 검토"
|
||||
)
|
||||
|
||||
# ── 5. 종합 적합성 점수 ──────────────────────────────
|
||||
suitability_score = regime_validation["confidence_score"]
|
||||
# 문제 건수에 따라 감점 (건당 10%, 최대 50% 감점)
|
||||
penalty = min(len(issues) * 0.10, 0.50)
|
||||
suitability_score = max(0.0, suitability_score - penalty)
|
||||
suitability_score = round(suitability_score, 3)
|
||||
|
||||
# ── 6. 보고서 생성 ───────────────────────────────────
|
||||
report = ValidationReport(
|
||||
ticker=ticker,
|
||||
kospi_level=kospi_price,
|
||||
regime=regime_analysis.regime.value,
|
||||
regime_description=regime_analysis.description,
|
||||
backtest_result=backtest_result,
|
||||
model_suitable=(suitability_score >= 0.5 and not regime_validation["should_replace"]),
|
||||
suitability_score=suitability_score,
|
||||
issues=issues,
|
||||
recommendations=list(set(recommendations)), # 중복 제거
|
||||
alternative_models=regime_validation.get("alternative_models", []),
|
||||
regime_strategy_hint=regime_analysis.recommended_strategy,
|
||||
risk_level=regime_analysis.risk_level,
|
||||
)
|
||||
|
||||
# ── 7. 캐시 저장 ─────────────────────────────────────
|
||||
self._cache[ticker] = {
|
||||
"timestamp": time.time(),
|
||||
"kospi_level": kospi_price,
|
||||
"regime": regime_analysis.regime.value,
|
||||
"suitability_score": suitability_score,
|
||||
"should_replace": regime_validation["should_replace"],
|
||||
"issue_count": len(issues),
|
||||
}
|
||||
self._save_cache()
|
||||
|
||||
return report
|
||||
|
||||
def get_cached(self, ticker: str) -> Optional[dict]:
|
||||
"""캐시된 검증 결과 반환 (24시간 이내)"""
|
||||
cached = self._cache.get(ticker)
|
||||
if not cached:
|
||||
return None
|
||||
if time.time() - cached.get("timestamp", 0) > _CACHE_TTL_SECONDS:
|
||||
return None
|
||||
return cached
|
||||
|
||||
def send_alert(self, report: ValidationReport):
|
||||
"""심각한 검증 결과 텔레그램 알림"""
|
||||
if not report.is_critical():
|
||||
return
|
||||
try:
|
||||
from modules.services.telegram import TelegramMessenger
|
||||
msg = (
|
||||
f"🚨 [모델 경고] {report.ticker}\n"
|
||||
f"코스피 {report.kospi_level:.0f} | 레짐: {report.regime}\n"
|
||||
f"적합성: {report.suitability_score:.0%}\n"
|
||||
)
|
||||
if report.issues:
|
||||
msg += "문제:\n" + "\n".join(f"• {i}" for i in report.issues[:3])
|
||||
if report.alternative_models:
|
||||
msg += f"\n권고 모델: {report.alternative_models[0]}"
|
||||
TelegramMessenger().send_message(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def generate_regime_report(self, kospi_price: float) -> str:
|
||||
"""코스피 수준만으로 빠른 레짐 보고서 생성 (백테스트 없음)"""
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi_price)
|
||||
validation = MarketRegimeDetector.validate_model_for_regime(regime_analysis.regime)
|
||||
|
||||
lines = [
|
||||
"=" * 55,
|
||||
f"📈 코스피 {kospi_price:.0f} 레짐 분석",
|
||||
"=" * 55,
|
||||
f"레짐 : {regime_analysis.regime.value}",
|
||||
f"설명 : {regime_analysis.description}",
|
||||
f"리스크 수준 : {regime_analysis.risk_level}",
|
||||
"",
|
||||
"─ 전략 파라미터 조정 ─",
|
||||
f"매수 임계값 : {'+' if regime_analysis.buy_threshold_adj >= 0 else ''}"
|
||||
f"{regime_analysis.buy_threshold_adj:+.2f} 조정",
|
||||
f"포지션 크기 : x{regime_analysis.position_size_adj:.2f}",
|
||||
f"LSTM 가중치 : {'+' if regime_analysis.lstm_weight_adj >= 0 else ''}"
|
||||
f"{regime_analysis.lstm_weight_adj:+.2f}",
|
||||
"",
|
||||
"─ 모델 평가 ─",
|
||||
f"현재 모델 적합: {'✅' if validation['is_suitable'] else '⚠️'} "
|
||||
f"(신뢰도 {validation['confidence_score']:.0%})",
|
||||
f"교체 필요 : {'예' if validation['should_replace'] else '아니오'}",
|
||||
f"권고사항 : {validation['recommendation']}",
|
||||
]
|
||||
|
||||
if validation["alternative_models"]:
|
||||
lines.append("")
|
||||
lines.append("대안 모델 목록:")
|
||||
for model in validation["alternative_models"]:
|
||||
lines.append(f" • {model}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"📌 전략: {regime_analysis.recommended_strategy}")
|
||||
lines.append("=" * 55)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# 전역 싱글톤
|
||||
_validator_instance: Optional[ModelValidator] = None
|
||||
|
||||
|
||||
def get_validator() -> ModelValidator:
|
||||
"""ModelValidator 싱글톤 반환"""
|
||||
global _validator_instance
|
||||
if _validator_instance is None:
|
||||
_validator_instance = ModelValidator()
|
||||
return _validator_instance
|
||||
511
signal_v1/modules/analysis/technical.py
Normal file
511
signal_v1/modules/analysis/technical.py
Normal file
@@ -0,0 +1,511 @@
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
class TechnicalAnalyzer:
|
||||
"""
|
||||
Pandas를 활용한 기술적 지표 계산 모듈
|
||||
CPU 멀티코어 성능(9800X3D)을 십분 활용하기 위해 복잡한 연산은 여기서 처리
|
||||
|
||||
[v2.0 개선사항]
|
||||
- ATR(Average True Range): 변동성 기반 동적 손절/익절 산출
|
||||
- ADX(Average Directional Index): 추세 강도 측정 (방향 아닌 '강도')
|
||||
- OBV(On Balance Volume): 거래량 기반 매집/분산 감지
|
||||
- 다중 시간프레임(MTF): 5일/20일/60일 추세 일관성 확인
|
||||
- VWAP 근사: 거래량가중평균가격
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_rsi(prices, period=14):
|
||||
"""RSI(Relative Strength Index) 계산 - Wilder 방식 적용"""
|
||||
if len(prices) < period:
|
||||
return 50.0
|
||||
|
||||
delta = pd.Series(prices).diff()
|
||||
gain = delta.where(delta > 0, 0)
|
||||
loss = -delta.where(delta < 0, 0)
|
||||
|
||||
# Wilder의 지수이동평균 방식 (더 정확)
|
||||
avg_gain = gain.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
|
||||
avg_loss = loss.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
|
||||
|
||||
rs = avg_gain / (avg_loss + 1e-9)
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
return rsi.iloc[-1]
|
||||
|
||||
@staticmethod
|
||||
def calculate_atr(prices, high_prices=None, low_prices=None, period=14):
|
||||
"""ATR(Average True Range) 계산 - 동적 손절/익절의 핵심 지표
|
||||
|
||||
Returns:
|
||||
float: ATR 값 (가격 단위), 0이면 데이터 부족
|
||||
"""
|
||||
if len(prices) < period + 1:
|
||||
return 0.0
|
||||
|
||||
close = pd.Series(prices)
|
||||
|
||||
if high_prices and len(high_prices) == len(prices):
|
||||
high = pd.Series(high_prices)
|
||||
low = pd.Series(low_prices)
|
||||
else:
|
||||
# 고가/저가 없으면 종가 기반 추정 (일변동폭 1.5% 가정)
|
||||
high = close * 1.008
|
||||
low = close * 0.992
|
||||
|
||||
# True Range = max(H-L, |H-Cprev|, |L-Cprev|)
|
||||
prev_close = close.shift(1)
|
||||
tr1 = high - low
|
||||
tr2 = (high - prev_close).abs()
|
||||
tr3 = (low - prev_close).abs()
|
||||
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||
|
||||
# Wilder's smoothing
|
||||
atr = tr.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
|
||||
return atr.iloc[-1] if not pd.isna(atr.iloc[-1]) else 0.0
|
||||
|
||||
@staticmethod
|
||||
def calculate_adx(prices, high_prices=None, low_prices=None, period=14):
|
||||
"""ADX(Average Directional Index) - 추세 강도 측정
|
||||
|
||||
Returns:
|
||||
tuple: (adx, plus_di, minus_di)
|
||||
- ADX > 25: 강한 추세, ADX < 20: 횡보/비추세
|
||||
- +DI > -DI: 상승 추세, -DI > +DI: 하락 추세
|
||||
"""
|
||||
if len(prices) < period * 2:
|
||||
return 20.0, 50.0, 50.0 # 중립
|
||||
|
||||
close = pd.Series(prices)
|
||||
|
||||
if high_prices and len(high_prices) == len(prices):
|
||||
high = pd.Series(high_prices)
|
||||
low = pd.Series(low_prices)
|
||||
else:
|
||||
# 종가 기반 추정
|
||||
daily_range = close.pct_change().abs().rolling(5).mean().fillna(0.01) * close
|
||||
high = close + daily_range * 0.5
|
||||
low = close - daily_range * 0.5
|
||||
|
||||
# +DM, -DM
|
||||
plus_dm = high.diff()
|
||||
minus_dm = -low.diff()
|
||||
|
||||
plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0)
|
||||
minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0)
|
||||
|
||||
# ATR
|
||||
prev_close = close.shift(1)
|
||||
tr = pd.concat([
|
||||
high - low,
|
||||
(high - prev_close).abs(),
|
||||
(low - prev_close).abs()
|
||||
], axis=1).max(axis=1)
|
||||
|
||||
atr = tr.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
|
||||
|
||||
# +DI, -DI
|
||||
plus_di = 100 * plus_dm.ewm(alpha=1/period, min_periods=period, adjust=False).mean() / (atr + 1e-9)
|
||||
minus_di = 100 * minus_dm.ewm(alpha=1/period, min_periods=period, adjust=False).mean() / (atr + 1e-9)
|
||||
|
||||
# DX → ADX
|
||||
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di + 1e-9)
|
||||
adx = dx.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
|
||||
|
||||
return (
|
||||
adx.iloc[-1] if not pd.isna(adx.iloc[-1]) else 20.0,
|
||||
plus_di.iloc[-1] if not pd.isna(plus_di.iloc[-1]) else 50.0,
|
||||
minus_di.iloc[-1] if not pd.isna(minus_di.iloc[-1]) else 50.0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def calculate_obv(prices, volume_history):
|
||||
"""OBV(On Balance Volume) - 스마트머니 매집/분산 감지
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'obv_trend': 'ACCUMULATING' | 'DISTRIBUTING' | 'NEUTRAL',
|
||||
'obv_divergence': True/False (가격↑ but OBV↓ = 약세 다이버전스)
|
||||
}
|
||||
"""
|
||||
if not volume_history or len(volume_history) < 20 or len(prices) < 20:
|
||||
return {'obv_trend': 'NEUTRAL', 'obv_divergence': False, 'score': 0.0}
|
||||
|
||||
close = pd.Series(prices)
|
||||
volume = pd.Series(volume_history)
|
||||
|
||||
# OBV 계산
|
||||
direction = close.diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0))
|
||||
obv = (direction * volume).cumsum()
|
||||
|
||||
# OBV 추세 (20일 이동평균 대비)
|
||||
obv_ma = obv.rolling(20).mean()
|
||||
obv_current = obv.iloc[-1]
|
||||
obv_ma_current = obv_ma.iloc[-1]
|
||||
|
||||
if pd.isna(obv_ma_current):
|
||||
return {'obv_trend': 'NEUTRAL', 'obv_divergence': False, 'score': 0.0}
|
||||
|
||||
# 추세 판단
|
||||
obv_trend = 'NEUTRAL'
|
||||
score = 0.0
|
||||
if obv_current > obv_ma_current * 1.05:
|
||||
obv_trend = 'ACCUMULATING' # 매집 중
|
||||
score = 0.1
|
||||
elif obv_current < obv_ma_current * 0.95:
|
||||
obv_trend = 'DISTRIBUTING' # 분산 중
|
||||
score = -0.1
|
||||
|
||||
# 다이버전스 감지 (최근 10일)
|
||||
price_trend = close.iloc[-1] > close.iloc[-10] if len(close) >= 10 else False
|
||||
obv_price_trend = obv.iloc[-1] > obv.iloc[-10] if len(obv) >= 10 else False
|
||||
|
||||
divergence = False
|
||||
if price_trend and not obv_price_trend:
|
||||
divergence = True # 약세 다이버전스 (가격↑ OBV↓)
|
||||
score -= 0.05
|
||||
elif not price_trend and obv_price_trend:
|
||||
divergence = True # 강세 다이버전스 (가격↓ OBV↑)
|
||||
score += 0.05
|
||||
|
||||
return {
|
||||
'obv_trend': obv_trend,
|
||||
'obv_divergence': divergence,
|
||||
'score': round(score, 3)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_multi_timeframe_trend(prices):
|
||||
"""다중 시간프레임 추세 일관성 검사
|
||||
|
||||
5일(초단기), 20일(단기), 60일(중기) 추세가 일치하면 강한 신호
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'alignment': 'STRONG_BULL' | 'BULL' | 'NEUTRAL' | 'BEAR' | 'STRONG_BEAR',
|
||||
'score': -1.0 ~ 1.0,
|
||||
'details': {...}
|
||||
}
|
||||
"""
|
||||
if len(prices) < 60:
|
||||
return {'alignment': 'NEUTRAL', 'score': 0.0, 'details': {}}
|
||||
|
||||
p = pd.Series(prices)
|
||||
current = p.iloc[-1]
|
||||
|
||||
ma5 = p.rolling(5).mean().iloc[-1]
|
||||
ma20 = p.rolling(20).mean().iloc[-1]
|
||||
ma60 = p.rolling(60).mean().iloc[-1]
|
||||
|
||||
# 추세 방향 점수
|
||||
trends = []
|
||||
if current > ma5: trends.append(1)
|
||||
else: trends.append(-1)
|
||||
|
||||
if ma5 > ma20: trends.append(1)
|
||||
else: trends.append(-1)
|
||||
|
||||
if ma20 > ma60: trends.append(1)
|
||||
else: trends.append(-1)
|
||||
|
||||
total = sum(trends)
|
||||
|
||||
if total == 3:
|
||||
alignment = 'STRONG_BULL'
|
||||
score = 0.15
|
||||
elif total >= 1:
|
||||
alignment = 'BULL'
|
||||
score = 0.05
|
||||
elif total == -3:
|
||||
alignment = 'STRONG_BEAR'
|
||||
score = -0.15
|
||||
elif total <= -1:
|
||||
alignment = 'BEAR'
|
||||
score = -0.05
|
||||
else:
|
||||
alignment = 'NEUTRAL'
|
||||
score = 0.0
|
||||
|
||||
return {
|
||||
'alignment': alignment,
|
||||
'score': score,
|
||||
'details': {
|
||||
'ma5': round(ma5, 1),
|
||||
'ma20': round(ma20, 1),
|
||||
'ma60': round(ma60, 1),
|
||||
'price_vs_ma5': 'above' if current > ma5 else 'below'
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def calculate_dynamic_sl_tp(prices, high_prices=None, low_prices=None, atr_multiplier_sl=2.0, atr_multiplier_tp=3.0):
|
||||
"""ATR 기반 동적 손절/익절 계산
|
||||
|
||||
변동성에 맞는 적응형 손절/익절 라인 산출
|
||||
- 변동성 큰 종목: 넓은 손절폭 (whipsaw 방지)
|
||||
- 변동성 작은 종목: 좁은 손절폭 (빠른 리스크 관리)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'atr': ATR값,
|
||||
'atr_pct': ATR% (가격 대비),
|
||||
'stop_loss_pct': 손절 비율 (%),
|
||||
'take_profit_pct': 익절 비율 (%),
|
||||
'trailing_stop_pct': 트레일링 스탑 비율 (%)
|
||||
}
|
||||
"""
|
||||
if len(prices) < 15:
|
||||
return {
|
||||
'atr': 0, 'atr_pct': 0,
|
||||
'stop_loss_pct': -5.0, 'take_profit_pct': 8.0,
|
||||
'trailing_stop_pct': 3.0
|
||||
}
|
||||
|
||||
atr = TechnicalAnalyzer.calculate_atr(prices, high_prices, low_prices)
|
||||
current_price = prices[-1]
|
||||
|
||||
if current_price <= 0:
|
||||
return {
|
||||
'atr': 0, 'atr_pct': 0,
|
||||
'stop_loss_pct': -5.0, 'take_profit_pct': 8.0,
|
||||
'trailing_stop_pct': 3.0
|
||||
}
|
||||
|
||||
atr_pct = (atr / current_price) * 100
|
||||
|
||||
# 동적 손절: ATR x 2 (단, 최소 -3%, 최대 -10%)
|
||||
sl_pct = max(-10.0, min(-3.0, -atr_pct * atr_multiplier_sl))
|
||||
|
||||
# 동적 익절: ATR x 3 (단, 최소 +5%, 최대 +25%)
|
||||
tp_pct = max(5.0, min(25.0, atr_pct * atr_multiplier_tp))
|
||||
|
||||
# 트레일링 스탑: ATR x 1.5 (최고가 대비)
|
||||
trailing_pct = max(2.0, min(8.0, atr_pct * 1.5))
|
||||
|
||||
return {
|
||||
'atr': round(atr, 1),
|
||||
'atr_pct': round(atr_pct, 2),
|
||||
'stop_loss_pct': round(sl_pct, 2),
|
||||
'take_profit_pct': round(tp_pct, 2),
|
||||
'trailing_stop_pct': round(trailing_pct, 2)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def calculate_ma(prices, period=20):
|
||||
"""이동평균선(Moving Average) 계산"""
|
||||
if len(prices) < period:
|
||||
return prices[-1] if prices else 0
|
||||
return pd.Series(prices).rolling(window=period).mean().iloc[-1]
|
||||
|
||||
@staticmethod
|
||||
def calculate_macd(prices, fast=12, slow=26, signal=9):
|
||||
"""MACD (Moving Average Convergence Divergence) 계산"""
|
||||
if len(prices) < slow + signal:
|
||||
return 0, 0, 0 # 데이터 부족
|
||||
|
||||
s = pd.Series(prices)
|
||||
ema_fast = s.ewm(span=fast, adjust=False).mean()
|
||||
ema_slow = s.ewm(span=slow, adjust=False).mean()
|
||||
macd = ema_fast - ema_slow
|
||||
signal_line = macd.ewm(span=signal, adjust=False).mean()
|
||||
histogram = macd - signal_line
|
||||
|
||||
return macd.iloc[-1], signal_line.iloc[-1], histogram.iloc[-1]
|
||||
|
||||
@staticmethod
|
||||
def calculate_bollinger_bands(prices, period=20, num_std=2):
|
||||
"""Bollinger Bands 계산 (상단, 중단, 하단)"""
|
||||
if len(prices) < period:
|
||||
return 0, 0, 0
|
||||
|
||||
s = pd.Series(prices)
|
||||
sma = s.rolling(window=period).mean()
|
||||
std = s.rolling(window=period).std()
|
||||
|
||||
upper = sma + (std * num_std)
|
||||
lower = sma - (std * num_std)
|
||||
|
||||
return upper.iloc[-1], sma.iloc[-1], lower.iloc[-1]
|
||||
|
||||
@staticmethod
|
||||
def calculate_stochastic(prices, high_prices=None, low_prices=None, n=14, k=3, d=3):
|
||||
"""Stochastic Oscillator (Fast/Slow)
|
||||
고가/저가 데이터가 없으면 종가(prices)로 추정 계산
|
||||
"""
|
||||
if len(prices) < n:
|
||||
return 50, 50
|
||||
|
||||
close = pd.Series(prices)
|
||||
# 고가/저가 데이터가 별도로 없으면 종가로 대체 (정확도는 떨어짐)
|
||||
high = pd.Series(high_prices) if high_prices else close
|
||||
low = pd.Series(low_prices) if low_prices else close
|
||||
|
||||
# 최근 n일간 최고가/최저가
|
||||
highest_high = high.rolling(window=n).max()
|
||||
lowest_low = low.rolling(window=n).min()
|
||||
|
||||
# Fast %K
|
||||
fast_k = ((close - lowest_low) / (highest_high - lowest_low + 1e-9)) * 100
|
||||
# Slow %K (= Fast %D)
|
||||
slow_k = fast_k.rolling(window=k).mean()
|
||||
# Slow %D
|
||||
slow_d = slow_k.rolling(window=d).mean()
|
||||
|
||||
return slow_k.iloc[-1], slow_d.iloc[-1]
|
||||
|
||||
@staticmethod
|
||||
def get_technical_score(current_price, prices_history, volume_history=None):
|
||||
"""
|
||||
기술적 지표 통합 점수(0.0 ~ 1.0) 계산 (v2.0 고도화)
|
||||
|
||||
[v2.0 변경점]
|
||||
- RSI: 25% (30% → 25%, ADX에 비중 이전)
|
||||
- 이격도: 15% (20% → 15%)
|
||||
- MACD: 15% (20% → 15%)
|
||||
- Bollinger: 10% (15% → 10%)
|
||||
- Stochastic: 10% (15% → 10%)
|
||||
- ADX 추세강도: 15% (신규)
|
||||
- MTF 다중시간프레임: 10% (신규)
|
||||
- OBV/거래량 보너스: ±0.1 (보너스)
|
||||
"""
|
||||
if not prices_history or len(prices_history) < 30:
|
||||
return 0.5, 50.0, 0.0, 1.0, {"ma20": 0, "ma114": 0, "trend": "Unknown", "position": "Unknown"}
|
||||
|
||||
scores = []
|
||||
|
||||
# 1. RSI (비중 25%)
|
||||
rsi = TechnicalAnalyzer.calculate_rsi(prices_history)
|
||||
if rsi <= 30: rsi_score = 1.0
|
||||
elif rsi >= 70: rsi_score = 0.0
|
||||
else: rsi_score = 1.0 - ((rsi - 30) / 40.0)
|
||||
scores.append(rsi_score * 0.25)
|
||||
|
||||
# 2. 이격도 (비중 15%)
|
||||
ma20 = TechnicalAnalyzer.calculate_ma(prices_history, 20)
|
||||
disparity = (current_price - ma20) / (ma20 + 1e-9)
|
||||
if disparity < -0.05: disp_score = 1.0
|
||||
elif disparity > 0.05: disp_score = 0.0
|
||||
else: disp_score = 0.5 - (disparity * 10)
|
||||
scores.append(disp_score * 0.15)
|
||||
|
||||
# 3. MACD (비중 15%)
|
||||
macd, signal, hist = TechnicalAnalyzer.calculate_macd(prices_history)
|
||||
if hist > 0 and macd > 0: macd_score = 0.8
|
||||
elif hist > 0 and macd <= 0: macd_score = 0.65 # 골든크로스 초기 = 매수 기회
|
||||
elif hist < 0 and macd > 0: macd_score = 0.35 # 데드크로스 초기
|
||||
else: macd_score = 0.2
|
||||
scores.append(macd_score * 0.15)
|
||||
|
||||
# 4. Bollinger Bands (비중 10%)
|
||||
up, mid, low = TechnicalAnalyzer.calculate_bollinger_bands(prices_history)
|
||||
if current_price <= low:
|
||||
bb_score = 1.0
|
||||
elif current_price >= up:
|
||||
bb_score = 0.0
|
||||
else:
|
||||
pos = (current_price - low) / (up - low + 1e-9)
|
||||
bb_score = 1.0 - pos
|
||||
if current_price < low:
|
||||
bb_score = min(1.0, bb_score + 0.2)
|
||||
scores.append(bb_score * 0.10)
|
||||
|
||||
# 5. Stochastic (비중 10%)
|
||||
slow_k, slow_d = TechnicalAnalyzer.calculate_stochastic(prices_history)
|
||||
if slow_k < 20: st_score = 1.0
|
||||
elif slow_k > 80: st_score = 0.0
|
||||
else: st_score = 1.0 - (slow_k / 100.0)
|
||||
# 골든/데드크로스 보정
|
||||
if slow_k < 20 and slow_k > slow_d: # 과매도 영역에서 골든크로스
|
||||
st_score = min(1.0, st_score + 0.15)
|
||||
elif slow_k > 80 and slow_k < slow_d: # 과매수 영역에서 데드크로스
|
||||
st_score = max(0.0, st_score - 0.15)
|
||||
scores.append(st_score * 0.10)
|
||||
|
||||
# 6. [신규] ADX 추세 강도 (비중 15%)
|
||||
adx, plus_di, minus_di = TechnicalAnalyzer.calculate_adx(prices_history)
|
||||
if adx >= 25: # 강한 추세
|
||||
if plus_di > minus_di:
|
||||
adx_score = 0.8 + min(0.2, (adx - 25) / 50) # 강한 상승추세
|
||||
else:
|
||||
adx_score = 0.2 - min(0.2, (adx - 25) / 50) # 강한 하락추세
|
||||
else: # 비추세/횡보
|
||||
adx_score = 0.5 # 중립
|
||||
adx_score = max(0.0, min(1.0, adx_score))
|
||||
scores.append(adx_score * 0.15)
|
||||
|
||||
# 7. [신규] 다중 시간프레임 (비중 10%)
|
||||
mtf = TechnicalAnalyzer.get_multi_timeframe_trend(prices_history)
|
||||
# MTF score를 0~1 범위로 변환
|
||||
mtf_score = 0.5 + mtf['score'] # -0.15~+0.15 → 0.35~0.65
|
||||
mtf_score = max(0.0, min(1.0, mtf_score))
|
||||
scores.append(mtf_score * 0.10)
|
||||
|
||||
total_score = sum(scores)
|
||||
|
||||
# [보너스] 거래량 분석 (Whale Tracking + OBV)
|
||||
volume_ratio = 1.0
|
||||
if volume_history and len(volume_history) >= 5:
|
||||
vol_s = pd.Series(volume_history)
|
||||
avg_vol = vol_s.rolling(window=5).mean().iloc[-2]
|
||||
current_vol = volume_history[-1]
|
||||
if avg_vol > 0:
|
||||
volume_ratio = current_vol / avg_vol
|
||||
|
||||
# 거래량 폭증 보너스
|
||||
if volume_ratio >= 3.0:
|
||||
total_score += 0.08
|
||||
|
||||
# OBV 분석 보너스
|
||||
obv_result = TechnicalAnalyzer.calculate_obv(prices_history, volume_history)
|
||||
total_score += obv_result['score']
|
||||
|
||||
# MTF 추세 일관성 보너스 (위의 가중치 10% 외에 추가 보너스)
|
||||
if mtf['alignment'] == 'STRONG_BULL':
|
||||
total_score += 0.05
|
||||
elif mtf['alignment'] == 'STRONG_BEAR':
|
||||
total_score -= 0.05
|
||||
|
||||
# 0.0 ~ 1.0 클리핑
|
||||
total_score = max(0.0, min(1.0, total_score))
|
||||
|
||||
# 변동성(Volatility) 계산
|
||||
if len(prices_history) > 1:
|
||||
prices_np = np.array(prices_history)
|
||||
changes = np.diff(prices_np) / prices_np[:-1]
|
||||
volatility = np.std(changes) * 100
|
||||
else:
|
||||
volatility = 0.0
|
||||
|
||||
# 이동평균선 분석 (5일, 20일, 60일, 114일)
|
||||
ma5 = TechnicalAnalyzer.calculate_ma(prices_history, 5)
|
||||
ma60 = TechnicalAnalyzer.calculate_ma(prices_history, 60)
|
||||
ma114 = TechnicalAnalyzer.calculate_ma(prices_history, 114)
|
||||
|
||||
ma_trend = "Unknown"
|
||||
if ma5 > ma20 > ma60:
|
||||
ma_trend = "Bullish (Golden Alignment)"
|
||||
elif ma5 < ma20 < ma60:
|
||||
ma_trend = "Bearish (Dead Alignment)"
|
||||
elif ma20 > ma114:
|
||||
ma_trend = "Moderate Bullish"
|
||||
else:
|
||||
ma_trend = "Moderate Bearish"
|
||||
|
||||
price_pos = "Unknown"
|
||||
if current_price > ma20:
|
||||
price_pos = "Above MA20"
|
||||
else:
|
||||
price_pos = "Below MA20"
|
||||
|
||||
ma_info = {
|
||||
"ma5": round(ma5, 1),
|
||||
"ma20": round(ma20, 1),
|
||||
"ma60": round(ma60, 1),
|
||||
"ma114": round(ma114, 1),
|
||||
"trend": ma_trend,
|
||||
"position": price_pos,
|
||||
"adx": round(adx, 1),
|
||||
"adx_trend": "Strong" if adx >= 25 else "Weak/Sideways",
|
||||
"mtf_alignment": mtf['alignment']
|
||||
}
|
||||
|
||||
return round(total_score, 4), round(rsi, 2), round(volatility, 2), round(volume_ratio, 1), ma_info
|
||||
790
signal_v1/modules/bot.py
Normal file
790
signal_v1/modules/bot.py
Normal file
@@ -0,0 +1,790 @@
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from concurrent.futures.process import BrokenProcessPool
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from modules.config import Config
|
||||
from modules.services.kis import KISClient
|
||||
from modules.services.news import AsyncNewsCollector
|
||||
from modules.services.news_snapshot import NewsSnapshotStore
|
||||
from modules.services.ollama import OllamaManager
|
||||
from modules.services.telegram import TelegramMessenger
|
||||
from modules.analysis.macro import MacroAnalyzer
|
||||
from modules.utils.monitor import SystemMonitor
|
||||
from modules.utils.performance_db import PerformanceDB
|
||||
from modules.strategy.process import analyze_stock_process
|
||||
from modules.strategy.risk_gate import PortfolioRiskGate, RiskConfig
|
||||
from modules.strategy.daily_ledger import DailyLedger
|
||||
from modules.analysis.ensemble import get_ensemble
|
||||
|
||||
try:
|
||||
from theme_manager import ThemeManager
|
||||
except ImportError:
|
||||
class ThemeManager:
|
||||
def get_themes(self, code): return []
|
||||
|
||||
|
||||
def init_worker():
|
||||
try:
|
||||
from modules.utils.process_tracker import ProcessTracker
|
||||
ProcessTracker.register("Trading Bot Worker")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class AutoTradingBot:
|
||||
"""
|
||||
[v2.0] 개선된 자동매매 봇
|
||||
|
||||
주요 개선사항:
|
||||
1. ATR 기반 동적 손절/익절 + 트레일링 스탑
|
||||
2. 변동성 기반 포지션 사이징 (1주 고정 → 동적 수량)
|
||||
3. 보유종목 분석 기반 매도 (score 기반 SELL 판단)
|
||||
4. 매크로 상태를 분석 워커에 전달 (동적 임계값)
|
||||
5. 최고가 추적 (트레일링 스탑용)
|
||||
6. 상세한 매매 로그 및 텔레그램 알림
|
||||
"""
|
||||
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None):
|
||||
# 1. 서비스 초기화
|
||||
self.kis = KISClient()
|
||||
self.news_snapshot = NewsSnapshotStore("data/news_snapshots.db")
|
||||
self.news = AsyncNewsCollector(snapshot_store=self.news_snapshot)
|
||||
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
|
||||
|
||||
self.messenger = TelegramMessenger()
|
||||
self.theme_manager = ThemeManager()
|
||||
# 포트폴리오 리스크 게이트 (v3.2) — 테마 집중/동시보유 한도 검증
|
||||
self.risk_gate = PortfolioRiskGate(
|
||||
theme_lookup=lambda t: self.theme_manager.get_themes(t),
|
||||
config=RiskConfig(
|
||||
max_total_holdings=Config.MAX_TOTAL_HOLDINGS,
|
||||
max_tickers_per_theme=Config.MAX_TICKERS_PER_THEME,
|
||||
max_theme_exposure_ratio=Config.MAX_THEME_EXPOSURE_RATIO,
|
||||
),
|
||||
)
|
||||
self.ollama_monitor = OllamaManager()
|
||||
|
||||
# 2. 유틸리티 초기화
|
||||
self.monitor = SystemMonitor(self.messenger, self.ollama_monitor)
|
||||
|
||||
# 3. 상태 변수
|
||||
self.daily_trade_history = []
|
||||
self.discovered_stocks = set()
|
||||
self.is_macro_warning_sent = False
|
||||
self.watchlist_updated_today = False
|
||||
self.report_sent = False
|
||||
|
||||
# [v2.0] 트레일링 스탑용 최고가 추적
|
||||
# {ticker: peak_price}
|
||||
self.peak_prices = {}
|
||||
|
||||
# [v2.0] 최근 매크로 상태 캐싱
|
||||
self.last_macro_status = None
|
||||
|
||||
# [v3.2] 당일 상태 집약 (연속손절/당일매수/신호점수/플래그)
|
||||
self.ledger = DailyLedger()
|
||||
|
||||
# 4. 프로세스 관리
|
||||
self.shutdown_event = shutdown_event
|
||||
|
||||
# KRX 캘린더 (장 운영 여부 판단)
|
||||
from modules.utils.market_calendar import get_calendar
|
||||
self._calendar = get_calendar()
|
||||
|
||||
# 5. IPC (Shared Memory)
|
||||
try:
|
||||
from modules.utils.ipc import SharedIPC
|
||||
self.ipc = SharedIPC(lock=ipc_lock, command_queue=command_queue)
|
||||
except ImportError:
|
||||
print("[Bot] SharedIPC module not found.")
|
||||
self.ipc = None
|
||||
|
||||
# 6. Watchlist Manager
|
||||
try:
|
||||
from watchlist_manager import WatchlistManager
|
||||
self.watchlist_manager = WatchlistManager(self.kis, watchlist_file=Config.WATCHLIST_FILE)
|
||||
except ImportError:
|
||||
self.watchlist_manager = None
|
||||
|
||||
# 7. 기록 로드
|
||||
self.history_file = Config.HISTORY_FILE
|
||||
self.load_trade_history()
|
||||
|
||||
# 7-1. 성과 DB 및 수동 평가 요청 플래그 (주간/스냅샷 플래그는 ledger로 이관)
|
||||
self.perf_db = PerformanceDB()
|
||||
self._pending_evaluate = False
|
||||
|
||||
# 8. AI 하드웨어 점검
|
||||
from modules.analysis.deep_learning import PricePredictor
|
||||
PricePredictor.verify_hardware()
|
||||
|
||||
# 9. KIS 비동기 클라이언트
|
||||
try:
|
||||
from modules.services.kis import KISAsyncClient
|
||||
self.kis_async = KISAsyncClient(self.kis)
|
||||
except ImportError:
|
||||
self.kis_async = None
|
||||
|
||||
def load_trade_history(self):
|
||||
if os.path.exists(self.history_file):
|
||||
try:
|
||||
with open(self.history_file, "r", encoding="utf-8") as f:
|
||||
self.daily_trade_history = json.load(f)
|
||||
except Exception:
|
||||
self.daily_trade_history = []
|
||||
else:
|
||||
self.daily_trade_history = []
|
||||
|
||||
def save_trade_history(self):
|
||||
try:
|
||||
with open(self.history_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.daily_trade_history, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Bot] Failed to save history: {e}")
|
||||
|
||||
def load_watchlist(self):
|
||||
try:
|
||||
with open(Config.WATCHLIST_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _take_daily_snapshot(self, macro_status, balance):
|
||||
"""일별 자산 스냅샷을 perf_db에 저장 (09:05~09:15 호출)."""
|
||||
try:
|
||||
total_eval_snap = int(balance.get("total_eval", 0))
|
||||
deposit_snap = int(balance.get("deposit", 0))
|
||||
holdings_count_snap = len([
|
||||
h for h in balance.get("holdings", [])
|
||||
if int(h.get("qty", 0)) > 0
|
||||
])
|
||||
|
||||
# KOSPI 현재가 (macro_status 지표에서 추출)
|
||||
kospi_close = None
|
||||
try:
|
||||
indicators = macro_status.get("indicators", {})
|
||||
kospi_price = float(indicators.get("KOSPI", {}).get("price", 0))
|
||||
if kospi_price > 0:
|
||||
kospi_close = kospi_price
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.perf_db.save_daily_snapshot(
|
||||
total_eval_snap, deposit_snap, holdings_count_snap, kospi_close)
|
||||
self.ledger.snapshot_taken = True
|
||||
except Exception as e:
|
||||
print(f"[Bot] Daily snapshot error: {e}")
|
||||
|
||||
async def _run_weekly_evaluation(self):
|
||||
"""주간 성과 평가 실행 후 텔레그램으로 전송."""
|
||||
try:
|
||||
from modules.analysis.evaluator import PerformanceEvaluator
|
||||
evaluator = PerformanceEvaluator()
|
||||
loop = asyncio.get_running_loop()
|
||||
# Ollama 호출이 동기 블로킹이므로 executor에서 실행
|
||||
report = await loop.run_in_executor(None, evaluator.generate_weekly_report)
|
||||
if len(report) > 4000:
|
||||
report = report[:4000] + "\n... (일부 생략)"
|
||||
self.messenger.send_message(report)
|
||||
self.ledger.weekly_eval_sent = True
|
||||
print("[Bot] Weekly evaluation report sent.")
|
||||
except Exception as e:
|
||||
print(f"[Bot] Weekly evaluation error: {e}")
|
||||
self.messenger.send_message(f"[Bot] 주간 평가 오류: {e}")
|
||||
|
||||
def _load_peak_prices(self):
|
||||
"""트레일링 스탑용 최고가 데이터 로드"""
|
||||
peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json")
|
||||
if os.path.exists(peak_file):
|
||||
try:
|
||||
with open(peak_file, "r", encoding="utf-8") as f:
|
||||
self.peak_prices = json.load(f)
|
||||
except Exception:
|
||||
self.peak_prices = {}
|
||||
|
||||
def _save_peak_prices(self):
|
||||
"""트레일링 스탑용 최고가 데이터 저장"""
|
||||
peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json")
|
||||
try:
|
||||
with open(peak_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.peak_prices, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_peak_price(self, ticker, current_price):
|
||||
"""보유 종목의 최고가 갱신"""
|
||||
if ticker not in self.peak_prices:
|
||||
self.peak_prices[ticker] = current_price
|
||||
elif current_price > self.peak_prices[ticker]:
|
||||
self.peak_prices[ticker] = current_price
|
||||
print(f" 📈 [Peak Updated] {ticker}: {current_price:,.0f}")
|
||||
|
||||
def send_daily_report(self):
|
||||
if self.report_sent:
|
||||
return
|
||||
print("[Bot] Generating Daily Report...")
|
||||
balance = self.kis.get_balance()
|
||||
|
||||
total_eval = int(balance.get("total_eval", 0))
|
||||
deposit = int(balance.get("deposit", 0))
|
||||
|
||||
report = (f"📅 <b>[Daily Closing Report]</b>\n"
|
||||
f"💰 <b>Total Asset:</b> <code>{total_eval:,}원</code>\n"
|
||||
f"💵 <b>Cash:</b> <code>{deposit:,}원</code>\n"
|
||||
f"📜 <b>Trades Today:</b> <code>{len(self.daily_trade_history)}건</code>\n\n")
|
||||
|
||||
# 매매 내역
|
||||
if self.daily_trade_history:
|
||||
total_profit = 0
|
||||
buy_count = 0
|
||||
sell_count = 0
|
||||
for trade in self.daily_trade_history:
|
||||
action = trade['action']
|
||||
icon = "🔴" if action == "BUY" else "🔵"
|
||||
qty = trade.get('qty', 0)
|
||||
price = trade.get('price', 0)
|
||||
reason = trade.get('reason', '')
|
||||
report += f"{icon} <b>{action}</b> {trade['name']} {qty}주 @ {price:,.0f}원"
|
||||
if reason:
|
||||
report += f" ({reason})"
|
||||
report += "\n"
|
||||
|
||||
if action == "BUY":
|
||||
buy_count += 1
|
||||
else:
|
||||
sell_count += 1
|
||||
total_profit += trade.get('profit', 0)
|
||||
|
||||
report += f"\n📊 매수 {buy_count}건 / 매도 {sell_count}건"
|
||||
if sell_count > 0:
|
||||
report += f" | 실현손익: <code>{total_profit:,.0f}원</code>"
|
||||
report += "\n"
|
||||
|
||||
# 보유종목 현황
|
||||
if "holdings" in balance and balance["holdings"]:
|
||||
report += "\n📊 <b>[Holdings]</b>\n"
|
||||
for stock in balance["holdings"]:
|
||||
yld = float(stock.get('yield', 0))
|
||||
profit_loss = int(stock.get('profit_loss', 0))
|
||||
if yld > 0:
|
||||
icon = "🔴"
|
||||
yld_str = f"+{yld}"
|
||||
elif yld < 0:
|
||||
icon = "🔵"
|
||||
yld_str = f"{yld}"
|
||||
else:
|
||||
icon = "⚪"
|
||||
yld_str = f"{yld}"
|
||||
|
||||
report += (f"{icon} {stock['name']}: <code>{yld_str}%</code> "
|
||||
f"(<code>{profit_loss:+,}원</code>)\n")
|
||||
|
||||
self.messenger.send_message(report)
|
||||
self.report_sent = True
|
||||
|
||||
def restart_executor(self):
|
||||
print("[Bot] Restarting Process Executor...")
|
||||
try:
|
||||
self.executor.shutdown(wait=False)
|
||||
except Exception:
|
||||
pass
|
||||
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
|
||||
print("[Bot] Process Executor Restarted.")
|
||||
|
||||
def _process_commands(self):
|
||||
"""IPC command queue 폴링 및 처리"""
|
||||
if not self.ipc:
|
||||
return
|
||||
|
||||
commands = self.ipc.poll_commands()
|
||||
for cmd in commands:
|
||||
command = cmd.get('command', '')
|
||||
print(f"[Bot] Received command: {command}")
|
||||
|
||||
if command == 'restart':
|
||||
self.messenger.send_message("[Bot] Restart requested via Telegram.")
|
||||
self.restart_executor()
|
||||
|
||||
elif command == 'update_watchlist':
|
||||
if self.watchlist_manager:
|
||||
try:
|
||||
summary = self.watchlist_manager.update_watchlist_daily()
|
||||
self.messenger.send_message(f"[Watchlist Updated]\n{summary}")
|
||||
except Exception as e:
|
||||
self.messenger.send_message(f"Watchlist update failed: {e}")
|
||||
|
||||
elif command == 'evaluate':
|
||||
self._pending_evaluate = True
|
||||
|
||||
async def run_cycle(self):
|
||||
now = datetime.now()
|
||||
|
||||
# 0. 명령 큐 폴링
|
||||
self._process_commands()
|
||||
|
||||
# 0-1. 즉시 평가 요청 처리 (IPC 'evaluate' 명령)
|
||||
if self._pending_evaluate:
|
||||
self._pending_evaluate = False
|
||||
await self._run_weekly_evaluation()
|
||||
|
||||
# 1. 거시경제 분석
|
||||
macro_status = MacroAnalyzer.get_macro_status(self.kis)
|
||||
self.last_macro_status = macro_status
|
||||
is_crash = False
|
||||
|
||||
if macro_status['status'] == 'DANGER':
|
||||
is_crash = True
|
||||
if not self.is_macro_warning_sent:
|
||||
self.messenger.send_message(
|
||||
"🚨 <b>[MARKET CRASH ALERT]</b>\n"
|
||||
"시장 급락 감지! 매수 중단, 매도 기준 상향.\n"
|
||||
f"Risk Score: {macro_status['risk_score']}")
|
||||
self.is_macro_warning_sent = True
|
||||
elif macro_status['status'] == 'CAUTION':
|
||||
if not self.is_macro_warning_sent:
|
||||
self.messenger.send_message(
|
||||
"⚠️ <b>[MARKET CAUTION]</b>\n"
|
||||
"시장 불안정. 보수적 매매 모드 전환.\n"
|
||||
f"Risk Score: {macro_status['risk_score']}")
|
||||
self.is_macro_warning_sent = True
|
||||
else:
|
||||
if self.is_macro_warning_sent:
|
||||
self.messenger.send_message("🌤️ <b>[MARKET RECOVERY]</b> 시장 안정화.")
|
||||
self.is_macro_warning_sent = False
|
||||
|
||||
# 2. IPC 상태 업데이트
|
||||
if self.ipc:
|
||||
try:
|
||||
balance = self.kis.get_balance()
|
||||
gpu_status = self.ollama_monitor.get_gpu_status()
|
||||
watchlist = self.load_watchlist()
|
||||
self.ipc.write_status({
|
||||
'balance': balance,
|
||||
'gpu': gpu_status,
|
||||
'watchlist': watchlist,
|
||||
'discovered_stocks': list(self.discovered_stocks),
|
||||
'is_macro_warning': self.is_macro_warning_sent,
|
||||
'macro_indices': macro_status['indicators'],
|
||||
'themes': {}
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. 아침 업데이트 (08:00)
|
||||
if now.hour == 8 and 0 <= now.minute < 5:
|
||||
if not self.watchlist_updated_today and self.watchlist_manager:
|
||||
print("[Bot] Morning Update...")
|
||||
try:
|
||||
summary = self.watchlist_manager.update_watchlist_daily()
|
||||
self.messenger.send_message(summary)
|
||||
self.watchlist_updated_today = True
|
||||
except Exception as e:
|
||||
self.messenger.send_message(f"Update Failed: {e}")
|
||||
|
||||
# 4. 리셋 (09:00) — 일별 상태는 ledger.reset_if_new_day가 통합 관리
|
||||
if now.hour == 9 and now.minute < 5:
|
||||
self.daily_trade_history = []
|
||||
self.save_trade_history()
|
||||
self.report_sent = False
|
||||
self.discovered_stocks.clear()
|
||||
self.watchlist_updated_today = False
|
||||
self._load_peak_prices()
|
||||
if self.ledger.reset_if_new_day(now):
|
||||
print(f"[Bot] 일일 장부 리셋 (날짜: {now.date()})")
|
||||
|
||||
# 5. 시스템 감시 (3분 간격)
|
||||
self.monitor.check_health()
|
||||
|
||||
# 6. 장 운영 시간 체크
|
||||
if not (9 <= now.hour < 15 or (now.hour == 15 and now.minute < 30)):
|
||||
if now.hour == 15 and now.minute >= 40:
|
||||
self.send_daily_report()
|
||||
# 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록)
|
||||
if now.hour == 16 and now.minute <= 30 and not self.ledger.snapshot_taken:
|
||||
try:
|
||||
balance_snap = self.kis.get_balance()
|
||||
self._take_daily_snapshot(macro_status, balance_snap)
|
||||
except Exception as e:
|
||||
print(f"[Bot] Snapshot error: {e}")
|
||||
# 주간 평가 (금요일 15:35~15:45, 장 마감 직후)
|
||||
if (now.weekday() == 4 and now.hour == 15
|
||||
and 35 <= now.minute <= 45 and not self.ledger.weekly_eval_sent):
|
||||
await self._run_weekly_evaluation()
|
||||
|
||||
# 장 외 시간에는 서킷 브레이커도 리셋
|
||||
self.monitor.reset_circuit()
|
||||
print("[Bot] Market Closed. Waiting...")
|
||||
return
|
||||
|
||||
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
|
||||
if self.monitor.is_cpu_critical():
|
||||
print("[Bot] ⛔ CPU Circuit Breaker 발동 중. 분석 사이클 스킵.")
|
||||
return
|
||||
|
||||
cycle_start_time = time.time()
|
||||
print(f"[Bot] Cycle Start: {now.strftime('%H:%M:%S')}")
|
||||
|
||||
# 7. 종목 분석 및 매매
|
||||
target_dict = self.load_watchlist()
|
||||
|
||||
# [v2.0] 잔고 조회 및 보유종목 맵 생성
|
||||
balance = self.kis.get_balance()
|
||||
current_holdings = {}
|
||||
total_eval = int(balance.get("total_eval", 0))
|
||||
|
||||
if balance and "holdings" in balance:
|
||||
for stock in balance["holdings"]:
|
||||
code = stock.get("code")
|
||||
qty = int(stock.get("qty", 0))
|
||||
if qty > 0:
|
||||
current_holdings[code] = stock
|
||||
# 최고가 업데이트 (트레일링 스탑용)
|
||||
current_price = float(stock.get('current_price', 0))
|
||||
if current_price > 0:
|
||||
self._update_peak_price(code, current_price)
|
||||
|
||||
# [v2.0] 보유종목도 분석 대상에 포함 (watchlist에 없어도)
|
||||
for code in current_holdings:
|
||||
if code not in target_dict:
|
||||
name = current_holdings[code].get('name', 'Unknown')
|
||||
target_dict[code] = name
|
||||
print(f"[Bot] Added holding to analysis: {name} ({code})")
|
||||
|
||||
# 분석 실행 (병렬 처리)
|
||||
analysis_tasks = []
|
||||
news_data = await self.news.get_market_news_async()
|
||||
|
||||
raw_deposit = int(balance.get("deposit", 0))
|
||||
# 날짜 전환 안전망 (09:00 리셋 블록에서 누락됐을 가능성 대비)
|
||||
self.ledger.reset_if_new_day(now)
|
||||
|
||||
kis_today_buy = int(balance.get("today_buy_amt", 0))
|
||||
effective_today_buy = self.ledger.effective_today_buy(kis_today_buy)
|
||||
tracking_deposit = self.ledger.available_deposit(
|
||||
raw_deposit, Config.MAX_DAILY_BUY_RATIO, kis_today_buy
|
||||
)
|
||||
max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO)
|
||||
|
||||
print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | "
|
||||
f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)")
|
||||
|
||||
# [v3.0] 비동기 OHLCV + 투자자 동향 배치 조회
|
||||
tickers_list = list(target_dict.keys())
|
||||
ohlcv_batch = {}
|
||||
investor_batch = {}
|
||||
|
||||
if self.kis_async and tickers_list:
|
||||
try:
|
||||
print(f"[Bot] 비동기 OHLCV 배치 조회: {len(tickers_list)}종목")
|
||||
ohlcv_batch = await self.kis_async.get_daily_ohlcv_batch(tickers_list)
|
||||
investor_batch = await self.kis_async.get_investor_trends_batch(tickers_list)
|
||||
except Exception as e:
|
||||
print(f"[Bot] 비동기 배치 조회 실패: {e} -> 동기 fallback")
|
||||
ohlcv_batch = {}
|
||||
investor_batch = {}
|
||||
|
||||
# [v3.1] 사이클당 매수 횟수 제한
|
||||
buys_this_cycle = 0
|
||||
|
||||
try:
|
||||
for ticker, name in target_dict.items():
|
||||
# OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback)
|
||||
ohlcv_data = ohlcv_batch.get(ticker)
|
||||
if not ohlcv_data or not ohlcv_data.get('close'):
|
||||
ohlcv_data = self.kis.get_daily_ohlcv(ticker)
|
||||
if not ohlcv_data or not ohlcv_data.get('close'):
|
||||
continue
|
||||
|
||||
# [v2.0] 보유 정보 전달 (분석 워커에서 동적 손절/익절 사용)
|
||||
holding_info = None
|
||||
if ticker in current_holdings:
|
||||
h = current_holdings[ticker]
|
||||
holding_info = {
|
||||
'qty': int(h.get('qty', 0)),
|
||||
'yield': float(h.get('yield', 0.0)),
|
||||
'purchase_price': float(h.get('purchase_price', 0)),
|
||||
'current_price': float(h.get('current_price', 0)),
|
||||
'peak_price': self.peak_prices.get(ticker, float(h.get('current_price', 0)))
|
||||
}
|
||||
|
||||
# investor_trend fallback
|
||||
investor_trend = investor_batch.get(ticker)
|
||||
if investor_trend is None:
|
||||
investor_trend = self.kis.get_investor_trend(ticker)
|
||||
|
||||
future = self.executor.submit(
|
||||
analyze_stock_process, ticker, ohlcv_data, news_data,
|
||||
investor_trend, macro_status, holding_info,
|
||||
total_eval if total_eval > 0 else None)
|
||||
analysis_tasks.append(future)
|
||||
|
||||
# 결과 처리
|
||||
loop = asyncio.get_running_loop()
|
||||
for future in analysis_tasks:
|
||||
try:
|
||||
# 240초 타임아웃: LSTM 학습 + Ollama 추론 시간 고려
|
||||
res = await loop.run_in_executor(None, lambda f=future: f.result(240))
|
||||
ticker = res['ticker']
|
||||
ticker_name = target_dict.get(ticker, 'Unknown')
|
||||
print(f"[Bot] [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})"
|
||||
f" | SL:{res.get('sl_tp', {}).get('stop_loss_pct', 'N/A')}%"
|
||||
f" TP:{res.get('sl_tp', {}).get('take_profit_pct', 'N/A')}%")
|
||||
|
||||
# ===== 매수 처리 =====
|
||||
if res['decision'] == "BUY":
|
||||
if is_crash:
|
||||
print(f"[Bot] [Skip Buy] Market DANGER mode - {ticker_name}")
|
||||
continue
|
||||
|
||||
# [v3.1] 사이클당 최대 매수 종목 수 제한
|
||||
if buys_this_cycle >= Config.MAX_BUY_PER_CYCLE:
|
||||
print(f"[Bot] [Skip Buy] 사이클 최대 매수 횟수 초과 "
|
||||
f"({buys_this_cycle}/{Config.MAX_BUY_PER_CYCLE}) - {ticker_name}")
|
||||
continue
|
||||
|
||||
# [v2.1] 연속 손절 후 매수 일시 중단 체크
|
||||
if self.ledger.is_buy_paused(datetime.now()):
|
||||
print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: "
|
||||
f"{self.ledger.buy_paused_until.strftime('%H:%M')}) - {ticker_name}")
|
||||
continue
|
||||
|
||||
current_price = float(res['current_price'])
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
# [v3.1] 워커에서 Kelly Criterion으로 계산한 수량 직접 사용
|
||||
# (중복 계산 제거 — 워커가 total_eval 기준으로 이미 계산 완료)
|
||||
qty = res.get('suggested_qty', 0)
|
||||
if qty <= 0:
|
||||
print(f"[Bot] [Skip Buy] Position size = 0 ({ticker_name})")
|
||||
continue
|
||||
|
||||
required_amount = current_price * qty
|
||||
|
||||
# [v3.2] 포트폴리오 리스크 게이트 검증 (테마 집중/동시보유 상한)
|
||||
risk_holdings = [
|
||||
{"ticker": c, "eval_amount": int(float(h.get("current_price", 0))
|
||||
* int(h.get("qty", 0)))}
|
||||
for c, h in current_holdings.items()
|
||||
]
|
||||
risk_dec = self.risk_gate.evaluate_buy(
|
||||
ticker=ticker,
|
||||
candidate_amount=int(required_amount),
|
||||
current_holdings=risk_holdings,
|
||||
total_capital=max(total_eval, 1),
|
||||
)
|
||||
if not risk_dec.allowed:
|
||||
print(f"[Bot] [Skip Buy] RiskGate: {risk_dec.reason} ({ticker_name})")
|
||||
continue
|
||||
if risk_dec.max_allowed_amount < required_amount:
|
||||
new_qty = int(risk_dec.max_allowed_amount / current_price)
|
||||
if new_qty <= 0:
|
||||
print(f"[Bot] [Skip Buy] RiskGate 부분허용 금액 부족 ({ticker_name})")
|
||||
continue
|
||||
print(f"[Bot] RiskGate 부분허용: qty {qty}→{new_qty} "
|
||||
f"({risk_dec.reason})")
|
||||
qty = new_qty
|
||||
required_amount = current_price * qty
|
||||
|
||||
# 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액)
|
||||
if tracking_deposit < required_amount:
|
||||
qty = int(tracking_deposit / current_price)
|
||||
if qty <= 0:
|
||||
print(f"[Bot] [Skip Buy] 예수금 부족 ({ticker_name}): "
|
||||
f"필요 {required_amount:,.0f} > 가용 {tracking_deposit:,.0f}")
|
||||
continue
|
||||
required_amount = current_price * qty
|
||||
|
||||
print(f"[Bot] Buying {ticker_name} {qty}ea @ ~{current_price:,.0f}")
|
||||
order = self.kis.buy_stock(ticker, qty)
|
||||
if order.get("status"):
|
||||
reason = res.get('decision_reason', '')
|
||||
sl_tp = res.get('sl_tp', {})
|
||||
|
||||
msg = (f"🔴 <b>[BUY]</b> {ticker_name} {qty}주\n"
|
||||
f" Price: <code>{current_price:,.0f}원</code>\n"
|
||||
f" Score: <code>{res['score']:.2f}</code>\n"
|
||||
f" SL: <code>{sl_tp.get('stop_loss_pct', -5):.1f}%</code>"
|
||||
f" | TP: <code>{sl_tp.get('take_profit_pct', 8):.1f}%</code>"
|
||||
f" | Trail: <code>{sl_tp.get('trailing_stop_pct', 3):.1f}%</code>")
|
||||
if reason:
|
||||
msg += f"\n Reason: {reason}"
|
||||
|
||||
self.messenger.send_message(msg)
|
||||
self.daily_trade_history.append({
|
||||
"action": "BUY", "name": ticker_name,
|
||||
"qty": qty, "price": current_price,
|
||||
"score": res['score'],
|
||||
"reason": reason
|
||||
})
|
||||
self.save_trade_history()
|
||||
|
||||
# 성과 DB 기록
|
||||
pred = res.get("prediction") or {}
|
||||
self.perf_db.save_trade_record(
|
||||
action="BUY", ticker=ticker, name=ticker_name,
|
||||
qty=qty, price=current_price,
|
||||
scores_dict={
|
||||
"tech": res.get("tech", 0.0),
|
||||
"sentiment": res.get("sentiment", 0.0),
|
||||
"lstm_score": res.get("lstm_score", 0.0),
|
||||
"score": res.get("score", 0.0),
|
||||
"ai_confidence": res.get("ai_confidence", 0.5),
|
||||
"prediction_change": pred.get("change_rate", 0.0)
|
||||
},
|
||||
reason=reason,
|
||||
macro_state=macro_status.get("status", "SAFE")
|
||||
)
|
||||
|
||||
tracking_deposit -= required_amount
|
||||
self.ledger.record_buy(
|
||||
ticker, int(required_amount),
|
||||
{"tech": res.get("tech", 0.5),
|
||||
"sentiment": res.get("sentiment", 0.5),
|
||||
"lstm": res.get("lstm_score", 0.5)},
|
||||
)
|
||||
buys_this_cycle += 1
|
||||
print(f"[Bot] 당일 누적 매수: {self.ledger.today_buy_total:,}원 "
|
||||
f"(잔여 예수금: {tracking_deposit:,}원)")
|
||||
|
||||
# 최고가 초기 설정
|
||||
self.peak_prices[ticker] = current_price
|
||||
self._save_peak_prices()
|
||||
|
||||
# ===== 매도 처리 (v2.1 - 연속 손절 안전장치 포함) =====
|
||||
elif res['decision'] == "SELL" and ticker in current_holdings:
|
||||
h = current_holdings[ticker]
|
||||
qty = int(h.get('qty', 0))
|
||||
yld = float(h.get('yield', 0.0))
|
||||
profit_loss = int(h.get('profit_loss', 0))
|
||||
|
||||
if qty > 0:
|
||||
print(f"[Bot] Selling {ticker_name} {qty}ea (Yield: {yld:.1f}%)")
|
||||
sell_res = self.kis.sell_stock(ticker, qty)
|
||||
|
||||
if sell_res and sell_res.get("status"):
|
||||
reason = res.get('decision_reason', 'AI Signal')
|
||||
|
||||
msg = (f"🔵 <b>[SELL]</b> {ticker_name} {qty}주\n"
|
||||
f" Yield: <code>{yld:.1f}%</code>\n"
|
||||
f" P&L: <code>{profit_loss:+,}원</code>\n"
|
||||
f" Reason: {reason}")
|
||||
|
||||
self.messenger.send_message(msg)
|
||||
sell_price = float(h.get('current_price', 0))
|
||||
self.daily_trade_history.append({
|
||||
"action": "SELL", "name": ticker_name,
|
||||
"qty": qty, "price": sell_price,
|
||||
"yield": yld, "profit": profit_loss,
|
||||
"reason": reason
|
||||
})
|
||||
self.save_trade_history()
|
||||
|
||||
# 성과 DB 매도 결과 기록
|
||||
self.perf_db.close_trade(ticker, sell_price, yld)
|
||||
|
||||
# [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률)
|
||||
buy_sig = self.ledger.pop_buy_scores(ticker)
|
||||
if buy_sig is not None:
|
||||
try:
|
||||
get_ensemble().record_trade(
|
||||
ticker=ticker,
|
||||
tech_score=buy_sig["tech"],
|
||||
sentiment_score=buy_sig["sentiment"],
|
||||
lstm_score=buy_sig["lstm"],
|
||||
decision="BUY",
|
||||
outcome_pct=yld
|
||||
)
|
||||
print(f"[Bot] [Ensemble] {ticker_name} 학습 기록: "
|
||||
f"outcome={yld:+.1f}%")
|
||||
except Exception as _ee:
|
||||
print(f"[Bot] [Ensemble] record_trade 실패: {_ee}")
|
||||
|
||||
# [v2.1] 손절 횟수 추적 → 연속 N회 손절 시 매수 일시 중단
|
||||
triggered = self.ledger.record_sell_outcome(yld, datetime.now())
|
||||
if triggered:
|
||||
warn_msg = (
|
||||
f"⛔ <b>[매수 일시 중단]</b> 당일 손절 "
|
||||
f"{self.ledger.consecutive_stop_losses}회 → "
|
||||
f"{self.ledger.stop_loss_pause_minutes}분간 매수 정지 "
|
||||
f"(재개: {self.ledger.buy_paused_until.strftime('%H:%M')})"
|
||||
)
|
||||
self.messenger.send_message(warn_msg)
|
||||
print(f"[Bot] 연속 손절 {self.ledger.consecutive_stop_losses}회 → 매수 일시 중단")
|
||||
|
||||
# 최고가 기록 삭제
|
||||
if ticker in self.peak_prices:
|
||||
del self.peak_prices[ticker]
|
||||
self._save_peak_prices()
|
||||
|
||||
except BrokenProcessPool:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[Bot] Analysis Worker Error: {e}")
|
||||
|
||||
except BrokenProcessPool:
|
||||
print("[Bot] Worker Process Crashed. Restarting Executor...")
|
||||
self.restart_executor()
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[Bot] Cycle Loop Error: {e}")
|
||||
|
||||
# 사이클 소요시간 로깅 (120초 초과 시 경고)
|
||||
cycle_elapsed = time.time() - cycle_start_time
|
||||
if cycle_elapsed > 120:
|
||||
print(f"[Bot] ⚠️ 사이클 소요 {cycle_elapsed:.0f}초 (120초 초과) → LSTM 쿨다운 활성화 권장")
|
||||
else:
|
||||
print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}초")
|
||||
|
||||
def loop(self):
|
||||
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]")
|
||||
|
||||
_llm_label = (
|
||||
f"Gemini ({Config.GEMINI_MODEL})"
|
||||
if Config.GEMINI_API_KEY
|
||||
else f"Ollama ({Config.OLLAMA_MODEL})"
|
||||
)
|
||||
self.messenger.send_message(
|
||||
"🚀 <b>[Bot Started v3.1]</b>\n"
|
||||
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}분\n"
|
||||
f"✅ LLM 엔진: {_llm_label}\n"
|
||||
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
|
||||
f"✅ 장 상태: {self._calendar.status_summary()}\n"
|
||||
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
|
||||
|
||||
# 최고가 데이터 로드
|
||||
self._load_peak_prices()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if self.shutdown_event and self.shutdown_event.is_set():
|
||||
print("[Bot] Shutdown signal received.")
|
||||
break
|
||||
|
||||
try:
|
||||
loop.run_until_complete(self.run_cycle())
|
||||
except Exception as e:
|
||||
print(f"[Bot] Loop Error: {e}")
|
||||
self.messenger.send_message(f"[Bot] Loop Error: {e}")
|
||||
|
||||
for _ in range(60):
|
||||
if self.shutdown_event and self.shutdown_event.is_set():
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("[Bot] Stopped by User.")
|
||||
finally:
|
||||
print("[Bot] Shutting down executor...")
|
||||
self.executor.shutdown(wait=False)
|
||||
if self.ipc:
|
||||
self.ipc.cleanup()
|
||||
loop.close()
|
||||
print("[Bot] Executor shutdown complete.")
|
||||
124
signal_v1/modules/config.py
Normal file
124
signal_v1/modules/config.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env 파일 로드
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
class Config:
|
||||
# 1. 기본 설정
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 2. NAS 및 AI 서버
|
||||
NAS_API_URL = os.getenv("NAS_API_URL", "http://192.168.45.54:18500")
|
||||
OLLAMA_API_URL = os.getenv("OLLAMA_API_URL", "http://localhost:11434")
|
||||
# [최적화] qwen2.5:7b-instruct-q4_K_M: JSON 정확도↑, 속도↑, VRAM 4GB
|
||||
# 14B 원하면: qwen2.5:14b-instruct-q4_K_M (VRAM ~9GB, 품질 더 좋음)
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:7b-instruct-q4_K_M")
|
||||
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096")) # 8192→4096 (2배 속도)
|
||||
OLLAMA_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "200")) # 응답 토큰 제한
|
||||
OLLAMA_NUM_THREAD = int(os.getenv("OLLAMA_NUM_THREAD", "8")) # CPU 스레드 (9800X3D 최적화)
|
||||
|
||||
# 2-1. Gemini API (Primary LLM — Ollama 폴백)
|
||||
# API 키: https://aistudio.google.com/apikey 에서 무료 발급
|
||||
# 무료 티어: 15 RPM / 1,500 RPD (봇 필요량 ~240/일 → 여유 충분)
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||||
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
||||
|
||||
# 3. KIS 한국투자증권
|
||||
KIS_ENV_TYPE = os.getenv("KIS_ENV_TYPE", "virtual").lower()
|
||||
|
||||
if KIS_ENV_TYPE == "real":
|
||||
KIS_APP_KEY = os.getenv("KIS_REAL_APP_KEY")
|
||||
KIS_APP_SECRET = os.getenv("KIS_REAL_APP_SECRET")
|
||||
KIS_ACCOUNT = os.getenv("KIS_REAL_ACCOUNT")
|
||||
KIS_IS_VIRTUAL = False
|
||||
KIS_BASE_URL = "https://openapi.koreainvestment.com:9443"
|
||||
else:
|
||||
KIS_APP_KEY = os.getenv("KIS_VIRTUAL_APP_KEY")
|
||||
KIS_APP_SECRET = os.getenv("KIS_VIRTUAL_APP_SECRET")
|
||||
KIS_ACCOUNT = os.getenv("KIS_VIRTUAL_ACCOUNT")
|
||||
KIS_IS_VIRTUAL = True
|
||||
KIS_BASE_URL = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
# 4. 텔레그램
|
||||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
|
||||
|
||||
# 5. 매매 설정 (상수)
|
||||
MAX_INVESTMENT_PER_STOCK = 3000000 # 종목당 최대 300만원
|
||||
MAX_BUY_PER_CYCLE = int(os.getenv("MAX_BUY_PER_CYCLE", "2")) # 사이클당 최대 매수 종목 수
|
||||
EOD_SHUTDOWN_BUFFER_MIN = int(os.getenv("EOD_SHUTDOWN_BUFFER_MIN", "5")) # 장 마감 후 EOD 처리까지 대기 분
|
||||
MAX_DAILY_BUY_RATIO = float(os.getenv("MAX_DAILY_BUY_RATIO", "0.80")) # 예수금 대비 일일 최대 매수 비율
|
||||
|
||||
# 포트폴리오 리스크 게이트 (v3.2)
|
||||
MAX_TICKERS_PER_THEME = int(os.getenv("MAX_TICKERS_PER_THEME", "2")) # 테마당 최대 종목 수
|
||||
MAX_THEME_EXPOSURE_RATIO = float(os.getenv("MAX_THEME_EXPOSURE_RATIO", "0.40")) # 테마당 최대 노출 비율 (총자산 대비)
|
||||
MAX_TOTAL_HOLDINGS = int(os.getenv("MAX_TOTAL_HOLDINGS", "7")) # 총 보유 종목 수 상한
|
||||
|
||||
# 6. 데이터 경로
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
if not os.path.exists(DATA_DIR):
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
|
||||
HISTORY_FILE = os.path.join(DATA_DIR, "daily_trade_history.json")
|
||||
WATCHLIST_FILE = os.path.join(DATA_DIR, "watchlist.json")
|
||||
|
||||
# 모델 체크포인트 디렉토리
|
||||
MODEL_DIR = os.path.join(DATA_DIR, "models")
|
||||
if not os.path.exists(MODEL_DIR):
|
||||
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||
|
||||
# 7. IPC 설정
|
||||
SHM_NAME = "web_ai_bot_ipc"
|
||||
SHM_SIZE = 131072 # 128KB
|
||||
IPC_STALENESS = 600 # 600초 (LSTM 분석 사이클이 길어도 portfolio 명령어 정상 작동)
|
||||
|
||||
# 8. GPU 설정
|
||||
VRAM_WARNING_THRESHOLD = 12.0 # GB (14 → 12로 조기 경고)
|
||||
|
||||
# 9. 프로세스 관리
|
||||
WATCHDOG_INTERVAL = 30 # 헬스체크 간격(초)
|
||||
MAX_RESTART_COUNT = 3 # 최대 자동 재시작 횟수
|
||||
|
||||
# 10. 타임아웃 등
|
||||
HTTP_TIMEOUT = 10
|
||||
|
||||
# 11. LSTM 학습 최적화
|
||||
# 동일 종목을 이 시간(초) 내에 재학습하지 않음 → CPU/GPU 절약
|
||||
LSTM_COOLDOWN = int(os.getenv("LSTM_COOLDOWN", "1200")) # 20분
|
||||
# 체크포인트가 있을 때 빠른 재학습 에포크 수 (기존 50 → 30)
|
||||
LSTM_FAST_EPOCHS = int(os.getenv("LSTM_FAST_EPOCHS", "30"))
|
||||
|
||||
# 12. CPU 서킷 브레이커
|
||||
CPU_CIRCUIT_BREAKER_THRESHOLD = 92 # CPU% 이상 시 분석 스킵
|
||||
CPU_CIRCUIT_BREAKER_CONSECUTIVE = 2 # 연속 N회 초과 시 발동
|
||||
|
||||
# 13. AI 전문가 회의 (AICouncil) 설정
|
||||
# True: 매 분석 사이클에 회의 통합 (느림), False: 수동 호출만 허용
|
||||
AI_COUNCIL_ENABLED = os.getenv("AI_COUNCIL_ENABLED", "false").lower() == "true"
|
||||
# True: 의장 AI 단독 판단 (1회 LLM 호출), False: 전문가 4명 + 의장 (5회)
|
||||
AI_COUNCIL_FAST_MODE = os.getenv("AI_COUNCIL_FAST_MODE", "true").lower() == "true"
|
||||
# 종목당 최소 회의 간격(초) - 동일 종목 과다 호출 방지
|
||||
AI_COUNCIL_MIN_INTERVAL = int(os.getenv("AI_COUNCIL_MIN_INTERVAL", "3600")) # 1시간
|
||||
|
||||
# 14. 시장 레짐 / 코스피 목표 수준 설정
|
||||
# 코스피 레짐 감지 활성화 (process.py 임계값/포지션 자동 조정)
|
||||
MARKET_REGIME_ENABLED = os.getenv("MARKET_REGIME_ENABLED", "true").lower() == "true"
|
||||
# 모델 검증 활성화 (일일 1회 레짐 보고서 생성)
|
||||
MODEL_VALIDATION_ENABLED = os.getenv("MODEL_VALIDATION_ENABLED", "true").lower() == "true"
|
||||
# 코스피 목표/기준 수준 (레짐 전환 알림 기준)
|
||||
KOSPI_REFERENCE_LEVEL = float(os.getenv("KOSPI_REFERENCE_LEVEL", "2600"))
|
||||
|
||||
@staticmethod
|
||||
def validate():
|
||||
"""필수 설정 검증"""
|
||||
missing = []
|
||||
if not Config.KIS_APP_KEY: missing.append("KIS_APP_KEY")
|
||||
if not Config.KIS_APP_SECRET: missing.append("KIS_APP_SECRET")
|
||||
|
||||
if missing:
|
||||
print(f"⚠️ [Config] Missing Env Params: {', '.join(missing)}")
|
||||
return False
|
||||
return True
|
||||
950
signal_v1/modules/services/kis.py
Normal file
950
signal_v1/modules/services/kis.py
Normal file
@@ -0,0 +1,950 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
aiohttp = None
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class KISClient:
|
||||
"""
|
||||
한국투자증권 (Korea Investment & Securities) REST API Client
|
||||
"""
|
||||
def __init__(self, is_virtual=None):
|
||||
# Config에서 설정 로드
|
||||
self.app_key = Config.KIS_APP_KEY
|
||||
self.app_secret = Config.KIS_APP_SECRET
|
||||
self.cano = Config.KIS_ACCOUNT[:8]
|
||||
self.acnt_prdt_cd = Config.KIS_ACCOUNT[-2:] # "01" 등
|
||||
|
||||
# 가상/실전 모드 설정
|
||||
if is_virtual is None:
|
||||
self.is_virtual = Config.KIS_IS_VIRTUAL
|
||||
else:
|
||||
self.is_virtual = is_virtual
|
||||
|
||||
self.base_url = Config.KIS_BASE_URL
|
||||
|
||||
self.access_token = None
|
||||
self.token_expired = None
|
||||
self.last_req_time = 0
|
||||
|
||||
# 토큰 파일 경로 (영구 저장용)
|
||||
self.token_file = os.path.join(Config.DATA_DIR, "kis_token.json")
|
||||
self.load_token() # 초기화 시 토큰 로드 시도
|
||||
|
||||
def _safe_int(self, val):
|
||||
"""안전한 int 변환"""
|
||||
try:
|
||||
if not val:
|
||||
return 0
|
||||
return int(str(val).strip())
|
||||
except:
|
||||
return 0
|
||||
|
||||
def _throttle(self):
|
||||
"""API 요청 속도 제한 (초당 2회 이하로 제한)"""
|
||||
# 모의투자는 Rate Limit이 매우 엄격함 (초당 2~3회 권장)
|
||||
min_interval = 0.5 # 0.5초 대기 (초당 2회)
|
||||
now = time.time()
|
||||
elapsed = now - self.last_req_time
|
||||
|
||||
if elapsed < min_interval:
|
||||
time.sleep(min_interval - elapsed)
|
||||
|
||||
self.last_req_time = time.time()
|
||||
|
||||
def load_token(self):
|
||||
"""파일에서 토큰 로드"""
|
||||
if os.path.exists(self.token_file):
|
||||
try:
|
||||
with open(self.token_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# 만료 시간 체크
|
||||
expire_str = data.get("expired_at")
|
||||
if expire_str:
|
||||
expire_dt = datetime.strptime(expire_str, "%Y-%m-%d %H:%M:%S")
|
||||
if datetime.now() < expire_dt:
|
||||
self.access_token = data.get("access_token")
|
||||
self.token_expired = expire_dt
|
||||
print(f"📂 [KIS] Saved Token Loaded (Expires: {expire_str})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to load token file: {e}")
|
||||
|
||||
def save_token(self):
|
||||
"""토큰 파일 저장"""
|
||||
if not self.access_token or not self.token_expired:
|
||||
return
|
||||
|
||||
try:
|
||||
data = {
|
||||
"access_token": self.access_token,
|
||||
"expired_at": self.token_expired.strftime("%Y-%m-%d %H:%M:%S")
|
||||
}
|
||||
with open(self.token_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to save token file: {e}")
|
||||
|
||||
def _get_headers(self, tr_id=None):
|
||||
"""공통 헤더 생성"""
|
||||
headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"authorization": f"Bearer {self.access_token}",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret,
|
||||
}
|
||||
if tr_id:
|
||||
headers["tr_id"] = tr_id
|
||||
|
||||
return headers
|
||||
|
||||
def ensure_token(self, force=False):
|
||||
"""접근 토큰 발급 (OAuth 2.0) 및 유효성 관리"""
|
||||
# 토큰이 있고, 만료 시간이 아직 안 지났으면 재사용
|
||||
if not force and self.access_token and self.token_expired:
|
||||
if datetime.now() < self.token_expired:
|
||||
return
|
||||
|
||||
# 앱키 확인
|
||||
if not self.app_key or not self.app_secret:
|
||||
print("❌ [KIS] App Key or Secret is missing!")
|
||||
return
|
||||
|
||||
url = f"{self.base_url}/oauth2/tokenP"
|
||||
payload = {
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
|
||||
res = requests.post(url, json=payload, timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
self.access_token = data.get('access_token')
|
||||
|
||||
# 만료 시간 설정
|
||||
expires_in = int(data.get('expires_in', 86400))
|
||||
self.token_expired = datetime.now() + timedelta(seconds=expires_in - 60)
|
||||
|
||||
# 파일 저장
|
||||
self.save_token()
|
||||
|
||||
print(f"✅ [KIS] 토큰 발급 성공 (만료: {self.token_expired.strftime('%Y-%m-%d %H:%M:%S')})")
|
||||
|
||||
except Exception as e:
|
||||
# 1분 제한 에러 핸들링 (EGW00133)
|
||||
retry = False
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
err_text = e.response.text
|
||||
print(f"📄 [KIS Error]: {err_text}")
|
||||
if "EGW00133" in err_text:
|
||||
print("⏳ [KIS] Rate Limit Hit (1 min). Waiting 65s...")
|
||||
time.sleep(65) # 1분 대기
|
||||
retry = True
|
||||
|
||||
if retry:
|
||||
# 재귀 호출 (한 번만)
|
||||
self.ensure_token()
|
||||
return
|
||||
|
||||
print(f"❌ [KIS] 토큰 발급 실패: {e}")
|
||||
self.access_token = None
|
||||
raise e
|
||||
|
||||
def get_hash_key(self, datas):
|
||||
"""주문 시 필요한 Hash Key 생성 (Koreainvestment header 특화)"""
|
||||
url = f"{self.base_url}/uapi/hashkey"
|
||||
headers = {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
try:
|
||||
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
||||
return res.json()["HASH"]
|
||||
except Exception as e:
|
||||
print(f"❌ Hash Key 생성 실패: {e}")
|
||||
return None
|
||||
|
||||
def _request_api(self, method, endpoint, tr_id, params=None, data=None, use_hash=False):
|
||||
"""API 요청 공통 핸들러 (토큰 만료 시 자동 갱신)"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
url = f"{self.base_url}/{endpoint}"
|
||||
headers = self._get_headers(tr_id)
|
||||
|
||||
if use_hash and data:
|
||||
hash_key = self.get_hash_key(data)
|
||||
if hash_key:
|
||||
headers["hashkey"] = hash_key
|
||||
|
||||
try:
|
||||
if method == "GET":
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
else:
|
||||
res = requests.post(url, headers=headers, json=data,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
|
||||
# 토큰 만료 체크 (500 에러 or msg_cd 확인)
|
||||
is_token_error = False
|
||||
try:
|
||||
# KIS는 토큰 만료 시 500을 주거나 200/403 등과 함께 msg_cd로 알려줌
|
||||
if res.status_code == 500 or res.status_code == 401 or res.status_code == 403:
|
||||
err_data = res.json()
|
||||
# EGW00121: 유효하지 않은 토큰, EGW00123: 만료된 토큰
|
||||
if err_data.get('msg_cd') in ['EGW00121', 'EGW00123']:
|
||||
is_token_error = True
|
||||
except:
|
||||
pass
|
||||
|
||||
if is_token_error:
|
||||
print("🔄 [KIS] Token expired (caught). Refreshing...")
|
||||
self.ensure_token(force=True)
|
||||
headers = self._get_headers(tr_id)
|
||||
if use_hash and data and "hashkey" in headers:
|
||||
pass # Hash 재활용
|
||||
|
||||
if method == "GET":
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
else:
|
||||
res = requests.post(url, headers=headers, json=data,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [KIS] API Request Failed: {url} | {e}")
|
||||
if isinstance(e, requests.exceptions.RequestException) and e.response is not None:
|
||||
print(f"📄 [KIS Error Body]: {e.response.text}")
|
||||
raise e
|
||||
|
||||
def get_balance(self):
|
||||
"""주식 잔고 조회"""
|
||||
tr_id = "VTTC8434R" if self.is_virtual else "TTTC8434R"
|
||||
endpoint = "uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
# 쿼리 파라미터
|
||||
params = {
|
||||
"CANO": self.cano,
|
||||
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "00",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._request_api("GET", endpoint, tr_id, params=params)
|
||||
|
||||
# 응답 정리
|
||||
if data['rt_cd'] != '0':
|
||||
return {"error": data['msg1']}
|
||||
|
||||
holdings = []
|
||||
for item in data['output1']:
|
||||
if int(item['hldg_qty']) > 0:
|
||||
holdings.append({
|
||||
"code": item['pdno'],
|
||||
"name": item['prdt_name'],
|
||||
"qty": int(item['hldg_qty']),
|
||||
"yield": float(item['evlu_pfls_rt']),
|
||||
"purchase_price": float(item['pchs_avg_pric']), # 매입평균가
|
||||
"current_price": float(item['prpr']), # 현재가
|
||||
"profit_loss": int(item['evlu_pfls_amt']) # 평가손익
|
||||
})
|
||||
|
||||
summary = data['output2'][0]
|
||||
return {
|
||||
"holdings": holdings,
|
||||
"total_eval": int(summary['tot_evlu_amt']),
|
||||
"deposit": int(summary['dnca_tot_amt']),
|
||||
"today_buy_amt": int(summary.get('thdt_buy_amt', 0)), # 금일매수금액 (T+2 차감 전 당일 집행액)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def order(self, ticker, qty, buy_sell, price=0, order_type="market"):
|
||||
"""주문
|
||||
buy_sell: 'BUY' or 'SELL'
|
||||
order_type: 'market'(시장가), 'limit'(지정가), 'conditional'(조건부지정가)
|
||||
price: 지정가일 때 주문 가격 (market이면 무시)
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
# 모의투자/실전 TR ID 구분
|
||||
if buy_sell == 'BUY':
|
||||
tr_id = "VTTC0802U" if self.is_virtual else "TTTC0802U"
|
||||
else:
|
||||
tr_id = "VTTC0801U" if self.is_virtual else "TTTC0801U"
|
||||
|
||||
# 주문 구분 코드
|
||||
# 00: 지정가, 01: 시장가, 03: 최유리지정가, 05: 장전시간외, 06: 장후시간외
|
||||
if order_type == "limit" and price > 0:
|
||||
ord_dvsn = "00"
|
||||
ord_unpr = str(int(price))
|
||||
order_type_str = f"지정가({price:,.0f})"
|
||||
elif order_type == "conditional" and price > 0:
|
||||
ord_dvsn = "03" # 최유리지정가
|
||||
ord_unpr = str(int(price))
|
||||
order_type_str = f"조건부({price:,.0f})"
|
||||
else:
|
||||
ord_dvsn = "01" # 시장가
|
||||
ord_unpr = "0"
|
||||
order_type_str = "시장가"
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/trading/order-cash"
|
||||
|
||||
datas = {
|
||||
"CANO": self.cano,
|
||||
"ACNT_PRDT_CD": self.acnt_prdt_cd,
|
||||
"PDNO": ticker,
|
||||
"ORD_DVSN": ord_dvsn,
|
||||
"ORD_QTY": str(qty),
|
||||
"ORD_UNPR": ord_unpr
|
||||
}
|
||||
|
||||
headers = self._get_headers(tr_id=tr_id)
|
||||
hash_key = self.get_hash_key(datas)
|
||||
if hash_key:
|
||||
headers["hashkey"] = hash_key
|
||||
else:
|
||||
print("⚠️ [KIS] Hash Key 생성 실패 (주문 전송 시도)")
|
||||
|
||||
try:
|
||||
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea ({order_type_str})")
|
||||
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
print(f"📥 [KIS] 주문 응답 코드(rt_cd): {data['rt_cd']}")
|
||||
print(f"📥 [KIS] 주문 응답 메시지(msg1): {data['msg1']}")
|
||||
|
||||
if data['rt_cd'] != '0':
|
||||
return {"status": False, "msg": data['msg1'], "rt_cd": data['rt_cd']}
|
||||
|
||||
return {"status": True, "msg": "주문 전송 완료", "order_no": data['output']['ODNO'], "rt_cd": data['rt_cd']}
|
||||
except Exception as e:
|
||||
return {"status": False, "msg": str(e), "rt_cd": "EXCEPTION"}
|
||||
|
||||
def get_current_price(self, ticker):
|
||||
"""현재가 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||
headers = self._get_headers(tr_id="FHKST01010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
return int(data['output']['stck_prpr']) # 현재가
|
||||
except Exception as e:
|
||||
print(f"❌ 현재가 조회 실패: {e}")
|
||||
return None
|
||||
|
||||
def _get_daily_ohlcv_by_range(self, ticker, period="D", count=100):
|
||||
"""기간별시세 API (FHKST03010100) - OHLCV 전체 반환
|
||||
output2에서 stck_oprc, stck_hgpr, stck_lwpr, stck_clpr, acml_vol 파싱
|
||||
"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self._get_headers(tr_id="FHKST03010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
if data.get('rt_cd') != '0':
|
||||
return None
|
||||
|
||||
output = data.get('output2', [])
|
||||
if not output:
|
||||
return None
|
||||
|
||||
opens, highs, lows, closes, volumes = [], [], [], [], []
|
||||
for item in output:
|
||||
try:
|
||||
c = int(item.get('stck_clpr', 0) or 0)
|
||||
o = int(item.get('stck_oprc', 0) or 0)
|
||||
h = int(item.get('stck_hgpr', 0) or 0)
|
||||
l = int(item.get('stck_lwpr', 0) or 0)
|
||||
v = int(item.get('acml_vol', 0) or 0)
|
||||
if c > 0:
|
||||
opens.append(o if o > 0 else c)
|
||||
highs.append(h if h > 0 else c)
|
||||
lows.append(l if l > 0 else c)
|
||||
closes.append(c)
|
||||
volumes.append(v)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
if not closes:
|
||||
return None
|
||||
|
||||
# API는 최신순 → 과거→현재 순으로 변환
|
||||
opens.reverse(); highs.reverse(); lows.reverse()
|
||||
closes.reverse(); volumes.reverse()
|
||||
|
||||
result = {
|
||||
'open': opens[-count:],
|
||||
'high': highs[-count:],
|
||||
'low': lows[-count:],
|
||||
'close': closes[-count:],
|
||||
'volume': volumes[-count:]
|
||||
}
|
||||
print(f"[KIS] {ticker} OHLCV: {len(result['close'])}개 ({start_date}~{end_date})")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [KIS] OHLCV 조회 실패 ({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def get_daily_ohlcv(self, ticker, period="D", count=100):
|
||||
"""일별 OHLCV 시세 조회 (기술적 분석 + LSTM 7차원 입력용)
|
||||
1차: 기간별시세 API OHLCV 파싱 (100일)
|
||||
2차: 기존 close-only fallback
|
||||
"""
|
||||
ohlcv = self._get_daily_ohlcv_by_range(ticker, period, count)
|
||||
if ohlcv and len(ohlcv['close']) >= 30:
|
||||
return ohlcv
|
||||
|
||||
# fallback: close만 반환 (가짜 OHLCV)
|
||||
print(f"[KIS] {ticker} OHLCV 실패 → close-only fallback")
|
||||
prices = self._get_daily_price_by_range(ticker, period, count)
|
||||
if not prices:
|
||||
return None
|
||||
return {
|
||||
'open': prices, 'high': prices, 'low': prices,
|
||||
'close': prices, 'volume': []
|
||||
}
|
||||
|
||||
def _get_daily_price_by_range(self, ticker, period="D", count=100):
|
||||
"""기간별시세 API (FHKST03010100) - 날짜 범위로 최대 100일 데이터 반환
|
||||
inquire-daily-price(FHKST01010400)가 30일만 반환하는 한계 극복"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
# 영업일 count개 확보를 위해 역일 1.6배 요청 (주말/공휴일 여유)
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self._get_headers(tr_id="FHKST03010100")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
|
||||
if data.get('rt_cd') != '0':
|
||||
return []
|
||||
|
||||
# 기간별시세는 output2에 배열로 반환
|
||||
output = data.get('output2', [])
|
||||
if not output:
|
||||
return []
|
||||
|
||||
prices = []
|
||||
for item in output:
|
||||
clpr = item.get('stck_clpr', '')
|
||||
if clpr and clpr != '0':
|
||||
try:
|
||||
prices.append(int(clpr))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
prices.reverse() # API는 최신순 → 과거→현재 순으로 변환
|
||||
result = prices[-count:]
|
||||
print(f"[KIS] {ticker} 기간별시세: {len(result)}개 "
|
||||
f"({start_date}~{end_date})")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ [KIS] 기간별시세 조회 실패 ({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_daily_price(self, ticker, period="D", count=100):
|
||||
"""일별 시세 조회 (기술적 분석 + LSTM용)
|
||||
1차: 기간별시세 API (100일, LSTM 학습 가능)
|
||||
2차: 구형 API fallback (30일)
|
||||
"""
|
||||
# 1차: 기간별시세 API (FHKST03010100) - 100일
|
||||
prices = self._get_daily_price_by_range(ticker, period, count)
|
||||
if prices and len(prices) >= 30:
|
||||
return prices
|
||||
|
||||
# 2차: 구형 API fallback (FHKST01010400) - 30일
|
||||
print(f"[KIS] {ticker} 기간별시세 실패 → 구형 API(30일) fallback")
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self._get_headers(tr_id="FHKST01010400")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data.get('rt_cd') != '0':
|
||||
return []
|
||||
prices = [int(item['stck_clpr']) for item in data['output']
|
||||
if item.get('stck_clpr')]
|
||||
prices.reverse()
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 일별 시세 조회 실패 ({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_volume_rank(self, limit=5):
|
||||
"""거래량 상위 종목 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||
headers = self._get_headers(tr_id="FHPST01710000")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J", # 주식, ETF, ETN 전체
|
||||
"FID_COND_SCR_RSLT_GD_CD": "20171", # 전체
|
||||
"FID_INPUT_ISCD": "0000", # 전체
|
||||
"FID_DIV_CLS_CODE": "0", # 0: 전체
|
||||
"FID_BLNG_CLS_CODE": "0", # 0: 전체
|
||||
"FID_TRGT_CLS_CODE": "111111111", # 필터링 조건 (이대로 두면 됨)
|
||||
"FID_TRGT_EXCLS_CLS_CODE": "0000000000", # 제외 조건
|
||||
"FID_INPUT_PRICE_1": "",
|
||||
"FID_INPUT_PRICE_2": "",
|
||||
"FID_VOL_CNT": "",
|
||||
"FID_INPUT_DATE_1": ""
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
results = []
|
||||
for item in data['output'][:limit]:
|
||||
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
|
||||
results.append({
|
||||
"code": item['mksc_shrn_iscd'],
|
||||
"name": item['hts_kor_isnm'],
|
||||
"volume": int(item['acml_vol']),
|
||||
"price": int(item['stck_prpr'])
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"❌ 거래량 순위 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def buy_stock(self, ticker, qty):
|
||||
return self.order(ticker, qty, 'BUY')
|
||||
|
||||
def get_current_index(self, ticker):
|
||||
"""지수 현재가 조회 (업종/지수)
|
||||
ticker: 0001 (KOSPI), 1001 (KOSDAQ), etc.
|
||||
"""
|
||||
endpoint = "uapi/domestic-stock/v1/quotations/inquire-index-price"
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._request_api("GET", endpoint, "FHKUP03500100", params=params)
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
o = data['output']
|
||||
def _f(val): return float(val) if val else 0.0
|
||||
def _i(val): return int(float(val)) if val else 0
|
||||
return {
|
||||
"price": _f(o.get('bstp_nmix_prpr')), # 현재지수
|
||||
"change": _f(o.get('bstp_nmix_prdy_ctrt')), # 등락률(%)
|
||||
"change_val": _f(o.get('bstp_nmix_prdy_vrss')), # 전일 대비 포인트
|
||||
"high": _f(o.get('bstp_nmix_hgpr')), # 장중 고가
|
||||
"low": _f(o.get('bstp_nmix_lwpr')), # 장중 저가
|
||||
"prev_close": _f(o.get('prdy_nmix')), # 전일 종가
|
||||
"volume": _i(o.get('acml_vol')), # 누적 거래량(천주)
|
||||
"trade_value": _i(o.get('acml_tr_pbmn')), # 누적 거래대금(백만원)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 조회 실패({ticker}): {e}")
|
||||
return None
|
||||
|
||||
def sell_stock(self, ticker, qty):
|
||||
return self.order(ticker, qty, 'SELL')
|
||||
|
||||
def get_daily_index_price(self, ticker, period="D"):
|
||||
"""지수 일별 시세 조회 (Market Stress Index용)"""
|
||||
endpoint = "uapi/domestic-stock/v1/quotations/inquire-daily-indexchartprice"
|
||||
|
||||
# 날짜 계산 (최근 100일)
|
||||
end_dt = datetime.now().strftime("%Y%m%d")
|
||||
start_dt = (datetime.now() - timedelta(days=100)).strftime("%Y%m%d")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "U", # U: 업종/지수
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_dt, # 시작일
|
||||
"FID_INPUT_DATE_2": end_dt, # 종료일
|
||||
"FID_PERIOD_DIV_CODE": period,
|
||||
"FID_ORG_ADJ_PRC": "0" # 수정주가 반영 여부
|
||||
}
|
||||
|
||||
try:
|
||||
data = self._request_api("GET", endpoint, "FHKUP03500200", params=params)
|
||||
if data['rt_cd'] != '0':
|
||||
return []
|
||||
|
||||
# output 리스트: [ {bstp_nmix_prpr: 지수, ...}, ... ]
|
||||
prices = [float(item['bstp_nmix_prpr']) for item in data['output']]
|
||||
prices.reverse() # 과거 -> 현재
|
||||
return prices
|
||||
except Exception as e:
|
||||
print(f"❌ 지수 일별 시세 조회 실패({ticker}): {e}")
|
||||
return []
|
||||
|
||||
def get_investor_trend(self, ticker):
|
||||
"""종목별 투자자(외인/기관) 매매동향 조회"""
|
||||
self._throttle()
|
||||
self.ensure_token()
|
||||
url = f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self._get_headers(tr_id="FHKST01010900")
|
||||
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
try:
|
||||
res = requests.get(url, headers=headers, params=params,
|
||||
timeout=Config.HTTP_TIMEOUT)
|
||||
res.raise_for_status()
|
||||
data = res.json()
|
||||
if data['rt_cd'] != '0':
|
||||
return None
|
||||
|
||||
trends = []
|
||||
for item in data['output'][:5]:
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self._safe_int(item.get('frgn_ntby_qty')),
|
||||
"institutional": self._safe_int(item.get('orgn_ntby_qty')),
|
||||
"price_change": float(item['prdy_vrss'])
|
||||
})
|
||||
|
||||
return trends
|
||||
except Exception as e:
|
||||
print(f"[KIS] 투자자 동향 조회 실패({ticker}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
class KISAsyncClient:
|
||||
"""
|
||||
비동기 KIS API 클라이언트
|
||||
- aiohttp 기반 HTTP 호출
|
||||
- 동기 KISClient의 토큰/설정을 공유
|
||||
- 다중 종목 병렬 수집용
|
||||
"""
|
||||
def __init__(self, sync_client):
|
||||
self.sync = sync_client
|
||||
self.min_interval = 0.5 # 초당 2회 제한
|
||||
|
||||
async def _async_get(self, session, url, headers, params):
|
||||
"""비동기 GET 요청"""
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=Config.HTTP_TIMEOUT) if aiohttp else None
|
||||
async with session.get(url, headers=headers, params=params,
|
||||
timeout=timeout) as resp:
|
||||
return await resp.json()
|
||||
except Exception as e:
|
||||
print(f"[KIS Async] Request failed: {e}")
|
||||
return None
|
||||
|
||||
async def get_daily_price_async(self, ticker):
|
||||
"""비동기 일별 시세 조회 (close only, 하위 호환)"""
|
||||
import aiohttp
|
||||
|
||||
self.sync.ensure_token()
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010400")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = await self._async_get(session, url, headers, params)
|
||||
if data and data.get('rt_cd') == '0':
|
||||
prices = [int(item['stck_clpr']) for item in data['output']]
|
||||
prices.reverse()
|
||||
return prices
|
||||
return []
|
||||
|
||||
async def get_daily_ohlcv_async(self, ticker, count=100):
|
||||
"""비동기 OHLCV 조회 (기간별시세 API 사용)"""
|
||||
import aiohttp
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
self.sync.ensure_token()
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self.sync._get_headers(tr_id="FHKST03010100")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = await self._async_get(session, url, headers, params)
|
||||
if data and data.get('rt_cd') == '0':
|
||||
output = data.get('output2', [])
|
||||
opens, highs, lows, closes, volumes = [], [], [], [], []
|
||||
for item in output:
|
||||
try:
|
||||
c = int(item.get('stck_clpr', 0) or 0)
|
||||
if c > 0:
|
||||
opens.append(int(item.get('stck_oprc', 0) or c))
|
||||
highs.append(int(item.get('stck_hgpr', 0) or c))
|
||||
lows.append(int(item.get('stck_lwpr', 0) or c))
|
||||
closes.append(c)
|
||||
volumes.append(int(item.get('acml_vol', 0) or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if closes:
|
||||
opens.reverse(); highs.reverse(); lows.reverse()
|
||||
closes.reverse(); volumes.reverse()
|
||||
return {
|
||||
'open': opens[-count:], 'high': highs[-count:],
|
||||
'low': lows[-count:], 'close': closes[-count:],
|
||||
'volume': volumes[-count:]
|
||||
}
|
||||
return None
|
||||
|
||||
async def get_investor_trend_async(self, ticker):
|
||||
"""비동기 투자자 동향 조회"""
|
||||
import aiohttp
|
||||
|
||||
self.sync.ensure_token()
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010900")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
data = await self._async_get(session, url, headers, params)
|
||||
if data and data.get('rt_cd') == '0':
|
||||
trends = []
|
||||
for item in data['output'][:5]:
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self.sync._safe_int(item.get('frgn_ntby_qty')),
|
||||
"institutional": self.sync._safe_int(item.get('orgn_ntby_qty')),
|
||||
"price_change": float(item['prdy_vrss'])
|
||||
})
|
||||
return trends
|
||||
return None
|
||||
|
||||
async def get_daily_prices_batch(self, tickers):
|
||||
"""여러 종목의 일별 시세(close only)를 병렬로 조회 (하위 호환)"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
self.sync.ensure_token()
|
||||
results = {}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = []
|
||||
for i, ticker in enumerate(tickers):
|
||||
if i > 0:
|
||||
await asyncio.sleep(self.min_interval)
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-price"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010400")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
||||
|
||||
for ticker, task in tasks:
|
||||
data = await task
|
||||
if data and data.get('rt_cd') == '0':
|
||||
prices = [int(item['stck_clpr']) for item in data['output']]
|
||||
prices.reverse()
|
||||
results[ticker] = prices
|
||||
else:
|
||||
results[ticker] = []
|
||||
|
||||
return results
|
||||
|
||||
async def get_daily_ohlcv_batch(self, tickers, count=100):
|
||||
"""여러 종목의 OHLCV를 병렬로 조회 (기간별시세 API)"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
self.sync.ensure_token()
|
||||
results = {}
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
start_date = (datetime.now() - timedelta(days=int(count * 1.6))).strftime("%Y%m%d")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = []
|
||||
for i, ticker in enumerate(tickers):
|
||||
if i > 0:
|
||||
await asyncio.sleep(self.min_interval)
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
headers = self.sync._get_headers(tr_id="FHKST03010100")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1"
|
||||
}
|
||||
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
||||
|
||||
for ticker, task in tasks:
|
||||
data = await task
|
||||
if data and data.get('rt_cd') == '0':
|
||||
output = data.get('output2', [])
|
||||
opens, highs, lows, closes, volumes = [], [], [], [], []
|
||||
for item in output:
|
||||
try:
|
||||
c = int(item.get('stck_clpr', 0) or 0)
|
||||
if c > 0:
|
||||
opens.append(int(item.get('stck_oprc', 0) or c))
|
||||
highs.append(int(item.get('stck_hgpr', 0) or c))
|
||||
lows.append(int(item.get('stck_lwpr', 0) or c))
|
||||
closes.append(c)
|
||||
volumes.append(int(item.get('acml_vol', 0) or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if closes:
|
||||
opens.reverse(); highs.reverse(); lows.reverse()
|
||||
closes.reverse(); volumes.reverse()
|
||||
results[ticker] = {
|
||||
'open': opens[-count:], 'high': highs[-count:],
|
||||
'low': lows[-count:], 'close': closes[-count:],
|
||||
'volume': volumes[-count:]
|
||||
}
|
||||
else:
|
||||
results[ticker] = None
|
||||
else:
|
||||
results[ticker] = None
|
||||
|
||||
return results
|
||||
|
||||
async def get_investor_trends_batch(self, tickers):
|
||||
"""여러 종목의 투자자 동향을 병렬로 조회"""
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
self.sync.ensure_token()
|
||||
results = {}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = []
|
||||
for i, ticker in enumerate(tickers):
|
||||
if i > 0:
|
||||
await asyncio.sleep(self.min_interval)
|
||||
|
||||
url = f"{self.sync.base_url}/uapi/domestic-stock/v1/quotations/inquire-investor"
|
||||
headers = self.sync._get_headers(tr_id="FHKST01010900")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker
|
||||
}
|
||||
tasks.append((ticker, self._async_get(session, url, headers, params)))
|
||||
|
||||
for ticker, task in tasks:
|
||||
data = await task
|
||||
if data and data.get('rt_cd') == '0':
|
||||
trends = []
|
||||
for item in data['output'][:5]:
|
||||
trends.append({
|
||||
"date": item['stck_bsop_date'],
|
||||
"foreigner": self.sync._safe_int(item.get('frgn_ntby_qty')),
|
||||
"institutional": self.sync._safe_int(item.get('orgn_ntby_qty')),
|
||||
"price_change": float(item['prdy_vrss'])
|
||||
})
|
||||
results[ticker] = trends
|
||||
else:
|
||||
results[ticker] = None
|
||||
|
||||
return results
|
||||
199
signal_v1/modules/services/llm_client.py
Normal file
199
signal_v1/modules/services/llm_client.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
통합 LLM 클라이언트 — Gemini 2.5 Flash (Primary) + Ollama (Fallback)
|
||||
|
||||
설계 원칙:
|
||||
- OllamaManager.request_inference(prompt) 와 동일한 인터페이스 유지
|
||||
→ process.py, ai_council.py 코드 변경 최소화
|
||||
- Gemini 실패(네트워크, Rate Limit) 시 자동으로 로컬 Ollama 폴백
|
||||
- 15 RPM 제한 준수를 위한 자동 스로틀링
|
||||
- VRAM 충돌 없음 (외부 API 호출이므로 LSTM 학습과 간섭 없음)
|
||||
|
||||
Rate Limit (Gemini 2.5 Flash 무료 티어):
|
||||
- 15 RPM, 1,500 RPD (봇 필요량 ~240/일 → 여유 6배)
|
||||
|
||||
추가 패키지 불필요:
|
||||
- requests (이미 설치됨) 기반 REST API 직접 호출
|
||||
"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
class GeminiLLMClient:
|
||||
"""
|
||||
Gemini API 클라이언트
|
||||
|
||||
사용법:
|
||||
client = GeminiLLMClient()
|
||||
result = client.request_inference(prompt) # str | None
|
||||
"""
|
||||
|
||||
_GENERATE_URL = (
|
||||
"https://generativelanguage.googleapis.com/v1beta/models"
|
||||
"/{model}:generateContent?key={key}"
|
||||
)
|
||||
# 15 RPM → 최소 4초 간격 (여유 0.1초 추가)
|
||||
_MIN_INTERVAL = 4.1
|
||||
# 클래스 변수: 같은 프로세스 내 재생성 시에도 마지막 호출 시각 유지
|
||||
# (워커 OOM 재시작 후 싱글톤 교체 시에도 스로틀 유효)
|
||||
_class_last_call_ts: float = 0.0
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = Config.GEMINI_API_KEY
|
||||
self.model = Config.GEMINI_MODEL
|
||||
self._ollama = None # Ollama 폴백 (lazy init)
|
||||
self._use_gemini = bool(self.api_key)
|
||||
|
||||
if self._use_gemini:
|
||||
print(f"✅ [LLMClient] Primary: Gemini {self.model}")
|
||||
else:
|
||||
print("⚠️ [LLMClient] GEMINI_API_KEY 미설정 → Ollama 전용 모드")
|
||||
|
||||
# ── 내부 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _throttle(self):
|
||||
"""15 RPM 제한 준수 — 최소 호출 간격 강제 대기 (클래스 공유 타임스탬프)"""
|
||||
elapsed = time.time() - GeminiLLMClient._class_last_call_ts
|
||||
if elapsed < self._MIN_INTERVAL:
|
||||
time.sleep(self._MIN_INTERVAL - elapsed)
|
||||
|
||||
def _call_gemini(self, prompt: str) -> str | None:
|
||||
"""
|
||||
Gemini REST API 단일 호출
|
||||
|
||||
설정:
|
||||
- systemInstruction: JSON 전용 응답 강제
|
||||
- thinkingBudget=0: 내부 추론 비활성 (속도 1.5초 / 토큰 절약)
|
||||
- maxOutputTokens=512: 200은 thinking 소모로 잘리므로 여유 확보
|
||||
"""
|
||||
self._throttle()
|
||||
|
||||
url = self._GENERATE_URL.format(model=self.model, key=self.api_key)
|
||||
payload = {
|
||||
"system_instruction": {
|
||||
"parts": [{"text": (
|
||||
"You are a Korean stock market analyst. "
|
||||
"Respond with valid JSON only. "
|
||||
"No markdown, no code blocks, no explanations."
|
||||
)}]
|
||||
},
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 512, # 200→512 (thinking 비활성 후 실제 응답 공간 확보)
|
||||
"temperature": 0.1, # 결정론적 출력
|
||||
"thinkingConfig": {"thinkingBudget": 0}, # 내부 추론 끔 (속도↑, 토큰↓)
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=30)
|
||||
GeminiLLMClient._class_last_call_ts = time.time()
|
||||
|
||||
# Rate Limit 초과
|
||||
if resp.status_code == 429:
|
||||
print("[LLMClient] Gemini Rate Limit (429) → Ollama 폴백")
|
||||
return None
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# thinking 파트 제외, 실제 텍스트 파트만 결합
|
||||
candidate = data.get("candidates", [{}])[0]
|
||||
parts = candidate.get("content", {}).get("parts", [])
|
||||
text = "".join(
|
||||
p.get("text", "") for p in parts
|
||||
if "text" in p and not p.get("thought")
|
||||
).strip()
|
||||
|
||||
return text if text else None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print("[LLMClient] Gemini Timeout (30s) → Ollama 폴백")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LLMClient] Gemini Error: {e} → Ollama 폴백")
|
||||
return None
|
||||
|
||||
def _get_ollama(self):
|
||||
"""Ollama 폴백 인스턴스 (lazy init — 필요할 때만 로드)"""
|
||||
if self._ollama is None:
|
||||
from modules.services.ollama import OllamaManager
|
||||
self._ollama = OllamaManager()
|
||||
# Ollama 실행 여부 사전 확인 (WinError 10061 조기 감지)
|
||||
try:
|
||||
requests.get(
|
||||
f"{Config.OLLAMA_API_URL}/api/tags",
|
||||
timeout=3,
|
||||
)
|
||||
except Exception:
|
||||
print(
|
||||
f"❌ [LLMClient] Ollama 미실행 (localhost:11434 연결 거부) — "
|
||||
f"`ollama serve` 명령으로 Ollama를 시작하세요."
|
||||
)
|
||||
return self._ollama
|
||||
|
||||
# ── 공개 인터페이스 ───────────────────────────────────────────────────────
|
||||
|
||||
def request_inference(self, prompt: str, context_data=None) -> str | None:
|
||||
"""
|
||||
LLM 추론 요청 — OllamaManager.request_inference()와 동일한 시그니처
|
||||
|
||||
순서:
|
||||
1) GEMINI_API_KEY 있음 → Gemini API 호출
|
||||
2) Gemini 실패(에러/타임아웃/Rate Limit) → Ollama 로컬 폴백
|
||||
3) GEMINI_API_KEY 없음 → 바로 Ollama 사용
|
||||
"""
|
||||
if self._use_gemini:
|
||||
result = self._call_gemini(prompt)
|
||||
if result is not None:
|
||||
return result
|
||||
# Gemini 실패 → Ollama 폴백
|
||||
print("[LLMClient] Ollama 폴백 시도 중...")
|
||||
|
||||
return self._get_ollama().request_inference(prompt, context_data)
|
||||
|
||||
# ── OllamaManager 호환 메서드 (ai_council, evaluator 등에서 사용) ─────────
|
||||
|
||||
def check_vram(self) -> float:
|
||||
"""VRAM 사용량 반환 (Ollama 측 정보, Gemini 호출 시엔 무관)"""
|
||||
if self._ollama:
|
||||
return self._ollama.check_vram()
|
||||
return 0.0
|
||||
|
||||
def get_gpu_status(self) -> dict:
|
||||
"""GPU 상태 반환 (OllamaManager 호환)"""
|
||||
return self._get_ollama().get_gpu_status()
|
||||
|
||||
def unload_model(self):
|
||||
"""Ollama 모델 언로드 (LSTM 학습 전 호출용, Gemini는 무작동)"""
|
||||
if self._ollama:
|
||||
try:
|
||||
requests.post(
|
||||
f"{Config.OLLAMA_API_URL}/api/generate",
|
||||
json={"model": Config.OLLAMA_MODEL, "keep_alive": 0},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 워커 프로세스 전역 싱글톤 ─────────────────────────────────────────────────
|
||||
|
||||
_llm_client: GeminiLLMClient | None = None
|
||||
|
||||
|
||||
def get_llm_client() -> GeminiLLMClient:
|
||||
"""
|
||||
워커 프로세스 내 GeminiLLMClient 싱글톤 반환
|
||||
|
||||
process.py에서 기존 get_ollama() 대신 이 함수를 사용:
|
||||
ollama = get_llm_client()
|
||||
result = ollama.request_inference(prompt)
|
||||
"""
|
||||
global _llm_client
|
||||
if _llm_client is None:
|
||||
_llm_client = GeminiLLMClient()
|
||||
return _llm_client
|
||||
122
signal_v1/modules/services/news.py
Normal file
122
signal_v1/modules/services/news.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import time
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_items(root, max_items):
|
||||
"""RSS item → [{title, url, pub_date, source}]"""
|
||||
out = []
|
||||
for item in root.findall(".//item")[:max_items]:
|
||||
t = item.find("title")
|
||||
l = item.find("link")
|
||||
p = item.find("pubDate")
|
||||
title = (t.text or "").strip() if t is not None else ""
|
||||
url = (l.text or "").strip() if l is not None else ""
|
||||
pub = (p.text or "").strip() if p is not None else ""
|
||||
if not title:
|
||||
continue
|
||||
out.append({"title": title, "url": url, "pub_date": pub, "source": "Google News"})
|
||||
return out
|
||||
|
||||
|
||||
class NewsCollector:
|
||||
"""동기 뉴스 수집 (Google News RSS)"""
|
||||
@staticmethod
|
||||
def get_market_news(query="주식 시장"):
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
root = ET.fromstring(resp.content)
|
||||
return _parse_items(root, 5)
|
||||
except Exception as e:
|
||||
print(f"[News] Collection failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
class AsyncNewsCollector:
|
||||
"""비동기 뉴스 수집 + 5분 캐싱 + (옵션) 스냅샷 저장"""
|
||||
|
||||
def __init__(self, snapshot_store=None):
|
||||
self._cache = None
|
||||
self._cache_time = 0
|
||||
self._cache_ttl = 300 # 5분
|
||||
self._stock_cache = {} # {stock_name: (items, timestamp)}
|
||||
self._snap = snapshot_store # NewsSnapshotStore | None
|
||||
|
||||
def _save_snapshot(self, items, query: str, ticker: Optional[str] = None):
|
||||
if not self._snap or not items:
|
||||
return
|
||||
try:
|
||||
self._snap.save_many(items, query=query, ticker=ticker)
|
||||
except Exception as e:
|
||||
print(f"[News] snapshot 저장 실패: {e}")
|
||||
|
||||
def get_market_news(self, query="주식 시장"):
|
||||
"""동기 인터페이스 (하위 호환)"""
|
||||
now = time.time()
|
||||
if self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return self._cache
|
||||
|
||||
result = NewsCollector.get_market_news(query)
|
||||
self._cache = result
|
||||
self._cache_time = now
|
||||
self._save_snapshot(result, query=query)
|
||||
return result
|
||||
|
||||
async def get_market_news_async(self, query="주식 시장"):
|
||||
"""비동기 뉴스 수집 (aiohttp + 캐싱)"""
|
||||
now = time.time()
|
||||
if self._cache and (now - self._cache_time) < self._cache_ttl:
|
||||
return self._cache
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
content = await resp.read()
|
||||
root = ET.fromstring(content)
|
||||
items = _parse_items(root, 5)
|
||||
self._cache = items
|
||||
self._cache_time = now
|
||||
self._save_snapshot(items, query=query)
|
||||
return items
|
||||
except ImportError:
|
||||
return self.get_market_news(query)
|
||||
except Exception as e:
|
||||
print(f"[News Async] Collection failed: {e}")
|
||||
if self._cache:
|
||||
return self._cache
|
||||
return self.get_market_news(query)
|
||||
|
||||
async def get_stock_news_async(self, stock_name, max_items=3, ticker: Optional[str] = None):
|
||||
"""종목별 뉴스 수집 (5분 캐싱)
|
||||
stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스')
|
||||
ticker: 스냅샷 저장 시 종목코드 (옵션)
|
||||
"""
|
||||
now = time.time()
|
||||
cached = self._stock_cache.get(stock_name)
|
||||
if cached and (now - cached[1]) < self._cache_ttl:
|
||||
return cached[0]
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
import urllib.parse
|
||||
query = urllib.parse.quote(f"{stock_name} 주가")
|
||||
url = f"https://news.google.com/rss/search?q={query}&hl=ko&gl=KR&ceid=KR:ko"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
content = await resp.read()
|
||||
root = ET.fromstring(content)
|
||||
items = _parse_items(root, max_items)
|
||||
self._stock_cache[stock_name] = (items, now)
|
||||
self._save_snapshot(items, query=f"{stock_name} 주가", ticker=ticker)
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}")
|
||||
return []
|
||||
|
||||
def clear_stock_cache(self):
|
||||
"""종목 뉴스 캐시 전체 초기화"""
|
||||
self._stock_cache.clear()
|
||||
189
signal_v1/modules/services/news_snapshot.py
Normal file
189
signal_v1/modules/services/news_snapshot.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
뉴스 스냅샷 인프라 (v3.2)
|
||||
|
||||
목적:
|
||||
- 수집한 뉴스를 SQLite에 타임스탬프와 함께 영구 저장
|
||||
- 사후 감성 신호 재검증 (LLM 재호출 / 모델 비교) 가능하게
|
||||
- 백테스트에서 '그 시점에 실제로 알 수 있던 뉴스'만 사용
|
||||
|
||||
스키마:
|
||||
news_snapshots(
|
||||
id INTEGER PK,
|
||||
captured_at TEXT, # ISO8601 (KST) — 수집 시점
|
||||
query TEXT, # 수집 쿼리 (예: '주식 시장', '삼성전자')
|
||||
ticker TEXT, # 종목 코드 (종목 뉴스일 때, else NULL)
|
||||
title TEXT,
|
||||
url TEXT UNIQUE,
|
||||
pub_date TEXT, # RSS pubDate 원본
|
||||
source TEXT DEFAULT 'google_news'
|
||||
)
|
||||
sentiment_scores( # 야간 배치로 사후 생성
|
||||
news_id INTEGER PK,
|
||||
scored_at TEXT,
|
||||
model TEXT,
|
||||
sentiment REAL, # -1.0 ~ 1.0
|
||||
confidence REAL,
|
||||
raw_json TEXT,
|
||||
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
|
||||
)
|
||||
|
||||
순수 I/O 모듈 — 네트워크 의존성 없음 → unit 테스트 가능.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Iterable, List, Optional, Dict
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
class NewsSnapshotStore:
|
||||
"""
|
||||
SQLite 기반 뉴스 스냅샷 저장소.
|
||||
|
||||
사용 예:
|
||||
store = NewsSnapshotStore("data/news_snapshots.db")
|
||||
store.save_many(items, query="삼성전자", ticker="005930")
|
||||
rows = store.query_between(start, end, ticker="005930")
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 스키마
|
||||
# ──────────────────────────────────────────────
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self):
|
||||
with self._connect() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS news_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
captured_at TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
ticker TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
pub_date TEXT,
|
||||
source TEXT DEFAULT 'google_news'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_captured
|
||||
ON news_snapshots(captured_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_ticker
|
||||
ON news_snapshots(ticker, captured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sentiment_scores (
|
||||
news_id INTEGER PRIMARY KEY,
|
||||
scored_at TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
sentiment REAL NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
raw_json TEXT,
|
||||
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
|
||||
);
|
||||
""")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 쓰기
|
||||
# ──────────────────────────────────────────────
|
||||
def save_many(self, items: Iterable[Dict], query: str,
|
||||
ticker: Optional[str] = None,
|
||||
captured_at: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
뉴스 다건 저장. URL 기준 중복 자동 무시.
|
||||
|
||||
Args:
|
||||
items: [{"title": str, "url": str, "pub_date": str?}, ...]
|
||||
|
||||
Returns:
|
||||
실제로 삽입된 행 수
|
||||
"""
|
||||
if captured_at is None:
|
||||
captured_at = datetime.now(KST)
|
||||
ts = captured_at.isoformat()
|
||||
|
||||
rows = []
|
||||
for it in items:
|
||||
title = (it.get("title") or "").strip()
|
||||
url = (it.get("url") or "").strip()
|
||||
if not title or not url:
|
||||
continue
|
||||
rows.append((ts, query, ticker, title, url, it.get("pub_date")))
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
with self._connect() as conn:
|
||||
before = conn.total_changes
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO news_snapshots "
|
||||
"(captured_at, query, ticker, title, url, pub_date) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
inserted = conn.total_changes - before
|
||||
return inserted
|
||||
|
||||
def save_sentiment(self, news_id: int, model: str,
|
||||
sentiment: float, confidence: float,
|
||||
raw_json: str = "",
|
||||
scored_at: Optional[datetime] = None) -> None:
|
||||
if scored_at is None:
|
||||
scored_at = datetime.now(KST)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sentiment_scores "
|
||||
"(news_id, scored_at, model, sentiment, confidence, raw_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(news_id, scored_at.isoformat(), model,
|
||||
float(sentiment), float(confidence), raw_json),
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 읽기
|
||||
# ──────────────────────────────────────────────
|
||||
def query_between(self, start: datetime, end: datetime,
|
||||
ticker: Optional[str] = None,
|
||||
query: Optional[str] = None) -> List[sqlite3.Row]:
|
||||
"""특정 기간 내 수집된 뉴스 조회."""
|
||||
sql = "SELECT * FROM news_snapshots WHERE captured_at >= ? AND captured_at < ?"
|
||||
args = [start.isoformat(), end.isoformat()]
|
||||
if ticker is not None:
|
||||
sql += " AND ticker = ?"
|
||||
args.append(ticker)
|
||||
if query is not None:
|
||||
sql += " AND query = ?"
|
||||
args.append(query)
|
||||
sql += " ORDER BY captured_at ASC"
|
||||
with self._connect() as conn:
|
||||
return list(conn.execute(sql, args))
|
||||
|
||||
def pending_sentiment(self, limit: int = 100) -> List[sqlite3.Row]:
|
||||
"""아직 감성 점수가 없는 뉴스 반환 (야간 배치용)."""
|
||||
with self._connect() as conn:
|
||||
return list(conn.execute(
|
||||
"""SELECT n.* FROM news_snapshots n
|
||||
LEFT JOIN sentiment_scores s ON s.news_id = n.id
|
||||
WHERE s.news_id IS NULL
|
||||
ORDER BY n.captured_at DESC
|
||||
LIMIT ?""",
|
||||
(limit,)
|
||||
))
|
||||
|
||||
def stats(self) -> Dict:
|
||||
"""DB 통계 (row 수, 감성 커버리지)."""
|
||||
with self._connect() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM news_snapshots").fetchone()[0]
|
||||
scored = conn.execute("SELECT COUNT(*) FROM sentiment_scores").fetchone()[0]
|
||||
return {
|
||||
"total_news": total,
|
||||
"scored": scored,
|
||||
"pending": total - scored,
|
||||
"coverage_pct": (scored / total * 100) if total else 0.0,
|
||||
}
|
||||
136
signal_v1/modules/services/ollama.py
Normal file
136
signal_v1/modules/services/ollama.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import requests
|
||||
import json
|
||||
import psutil
|
||||
try:
|
||||
import pynvml
|
||||
except ImportError:
|
||||
pynvml = None
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class OllamaManager:
|
||||
"""
|
||||
Ollama API 세션 관리 및 메모리 누수 방지 래퍼
|
||||
- GPU VRAM 사용량 모니터링
|
||||
- keep_alive 파라미터를 통한 메모리 관리
|
||||
"""
|
||||
def __init__(self, model_name=None, base_url=None):
|
||||
self.model_name = model_name or Config.OLLAMA_MODEL
|
||||
self.base_url = base_url or Config.OLLAMA_API_URL
|
||||
self.generate_url = f"{self.base_url}/api/generate"
|
||||
|
||||
self.gpu_available = False
|
||||
try:
|
||||
if pynvml:
|
||||
pynvml.nvmlInit()
|
||||
self.handle = pynvml.nvmlDeviceGetHandleByIndex(0) # 0번 GPU (5070 Ti)
|
||||
self.gpu_available = True
|
||||
print("✅ [OllamaManager] NVIDIA GPU Monitoring On")
|
||||
else:
|
||||
print("⚠️ [OllamaManager] 'nvidia-ml-py' not installed. GPU monitoring disabled.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ [OllamaManager] GPU Init Failed: {e}")
|
||||
|
||||
def check_vram(self):
|
||||
"""현재 GPU VRAM 사용량(GB) 반환"""
|
||||
if not self.gpu_available:
|
||||
return 0.0
|
||||
try:
|
||||
info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
|
||||
used_gb = info.used / 1024**3
|
||||
return used_gb
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_gpu_status(self):
|
||||
"""GPU 종합 상태 반환 (온도, 메모리, 사용률, 이름)"""
|
||||
if not self.gpu_available:
|
||||
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
|
||||
|
||||
try:
|
||||
# GPU 이름
|
||||
name = pynvml.nvmlDeviceGetName(self.handle)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode('utf-8')
|
||||
|
||||
# 온도
|
||||
temp = pynvml.nvmlDeviceGetTemperature(self.handle, pynvml.NVML_TEMPERATURE_GPU)
|
||||
# 메모리
|
||||
mem_info = pynvml.nvmlDeviceGetMemoryInfo(self.handle)
|
||||
vram_used = mem_info.used / 1024**3
|
||||
vram_total = mem_info.total / 1024**3
|
||||
# 사용률
|
||||
util = pynvml.nvmlDeviceGetUtilizationRates(self.handle)
|
||||
load = util.gpu
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"temp": temp,
|
||||
"vram_used": round(vram_used, 1),
|
||||
"vram_total": round(vram_total, 1),
|
||||
"load": load
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"⚠️ GPU Status Check Failed: {e}")
|
||||
return {"name": "N/A", "temp": 0, "vram_used": 0, "vram_total": 0, "load": 0}
|
||||
|
||||
def is_training_active(self):
|
||||
"""LSTM 학습 중인지 확인 (GPU 메모리 충돌 방지)"""
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
# VRAM 사용량으로 학습 여부 추정
|
||||
vram = self.check_vram()
|
||||
return vram > Config.VRAM_WARNING_THRESHOLD
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def request_inference(self, prompt, context_data=None):
|
||||
"""
|
||||
Ollama에 추론 요청
|
||||
- LSTM 학습 중이면 대기 (GPU 메모리 충돌 방지)
|
||||
"""
|
||||
# LSTM 학습 중이면 최대 60초 대기
|
||||
import time as _time
|
||||
for _ in range(12):
|
||||
if not self.is_training_active():
|
||||
break
|
||||
print("[Ollama] Waiting for LSTM training to finish...")
|
||||
_time.sleep(5)
|
||||
|
||||
vram = self.check_vram()
|
||||
if vram > Config.VRAM_WARNING_THRESHOLD:
|
||||
print(f"[OllamaManager] High VRAM Usage ({vram:.1f}GB). Requesting unload.")
|
||||
try:
|
||||
# keep_alive=0으로 설정하여 모델 즉시 언로드
|
||||
requests.post(self.generate_url,
|
||||
json={"model": self.model_name, "keep_alive": 0}, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to unload model: {e}")
|
||||
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"num_ctx": Config.OLLAMA_NUM_CTX, # 4096 (속도 2배)
|
||||
"num_predict": Config.OLLAMA_NUM_PREDICT, # 응답 토큰 제한
|
||||
"temperature": 0.1, # 더 결정론적 (JSON 파싱 안정성)
|
||||
"num_gpu": 1,
|
||||
"num_thread": Config.OLLAMA_NUM_THREAD # Config 설정값 (기본 8)
|
||||
},
|
||||
"keep_alive": "5m" # 5분 유지 (불필요한 VRAM 점유 줄임)
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(self.generate_url, json=payload, timeout=90) # 180→90초
|
||||
response.raise_for_status()
|
||||
return response.json().get('response')
|
||||
except requests.exceptions.Timeout:
|
||||
print(f"❌ Inference Timeout (90s): {self.model_name}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Inference Error: {e}")
|
||||
return None
|
||||
34
signal_v1/modules/services/telegram.py
Normal file
34
signal_v1/modules/services/telegram.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import requests
|
||||
import os
|
||||
import threading
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
class TelegramMessenger:
|
||||
def __init__(self, token=None, chat_id=None):
|
||||
# 환경 변수에서 로드하거나 인자로 받음
|
||||
self.token = token or Config.TELEGRAM_BOT_TOKEN
|
||||
self.chat_id = chat_id or Config.TELEGRAM_CHAT_ID
|
||||
|
||||
if not self.token or not self.chat_id:
|
||||
print("⚠️ [Telegram] Token or Chat ID not found.")
|
||||
|
||||
def send_message(self, message):
|
||||
"""별도 스레드로 메시지를 전송하여 메인 루프 블로킹 방지"""
|
||||
if not self.token or not self.chat_id:
|
||||
return
|
||||
|
||||
def _send():
|
||||
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
|
||||
payload = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "HTML"
|
||||
}
|
||||
try:
|
||||
requests.post(url, json=payload, timeout=5)
|
||||
except Exception as e:
|
||||
print(f"⚠️ [Telegram] Error: {e}")
|
||||
|
||||
# 스레드 실행 (Fire-and-forget)
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
91
signal_v1/modules/services/telegram_bot/runner.py
Normal file
91
signal_v1/modules/services/telegram_bot/runner.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
멀티프로세스 방식 - 텔레그램 봇 프로세스
|
||||
트레이딩 봇과 완전히 분리된 독립 프로세스로 실행
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
|
||||
|
||||
|
||||
def run_telegram_bot_standalone(ipc_lock=None, command_queue=None, shutdown_event=None):
|
||||
"""텔레그램 봇만 독립적으로 실행"""
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../')))
|
||||
|
||||
from modules.services.telegram_bot.server import TelegramBotServer
|
||||
from modules.utils.ipc import SharedIPC
|
||||
from modules.utils.process_tracker import ProcessTracker
|
||||
|
||||
token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
if not token:
|
||||
print("[Telegram] TELEGRAM_BOT_TOKEN not found in .env")
|
||||
sys.exit(1)
|
||||
|
||||
ProcessTracker.register("Telegram Bot Standalone")
|
||||
print(f"[Telegram Bot Process] Starting... (PID: {os.getpid()})")
|
||||
|
||||
# IPC 초기화 (shared memory + command queue)
|
||||
ipc = SharedIPC(lock=ipc_lock, command_queue=command_queue)
|
||||
|
||||
conflict_retries = 0
|
||||
MAX_CONFLICT_RETRIES = 10
|
||||
|
||||
while True:
|
||||
# shutdown 체크
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("[Telegram Bot] Shutdown signal received.")
|
||||
break
|
||||
|
||||
try:
|
||||
bot_server = TelegramBotServer(token, ipc=ipc, shutdown_event=shutdown_event)
|
||||
|
||||
# 초기 데이터 로드
|
||||
try:
|
||||
instance_data = ipc.get_bot_instance_data()
|
||||
if instance_data:
|
||||
bot_server.set_bot_instance(instance_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot_server.run()
|
||||
|
||||
if bot_server.should_restart:
|
||||
print("[Telegram Bot] Restarting instance...")
|
||||
conflict_retries = 0 # 정상 재시작 시 카운터 리셋
|
||||
time.sleep(1)
|
||||
continue
|
||||
else:
|
||||
print("[Telegram Bot] Process exiting.")
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("[Telegram Bot] Stopped by user")
|
||||
break
|
||||
except Exception as e:
|
||||
if "Conflict" in str(e):
|
||||
conflict_retries += 1
|
||||
if conflict_retries >= MAX_CONFLICT_RETRIES:
|
||||
print(f"[Telegram Bot] Conflict max retries ({MAX_CONFLICT_RETRIES}) reached. Exiting.")
|
||||
break
|
||||
wait_secs = min(5 * conflict_retries, 30)
|
||||
print(f"[Telegram Bot] Conflict detected. Waiting {wait_secs}s before retry "
|
||||
f"({conflict_retries}/{MAX_CONFLICT_RETRIES})...")
|
||||
time.sleep(wait_secs)
|
||||
continue
|
||||
else:
|
||||
print(f"[Telegram Bot] Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
# 정리
|
||||
ipc.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
run_telegram_bot_standalone()
|
||||
601
signal_v1/modules/services/telegram_bot/server.py
Normal file
601
signal_v1/modules/services/telegram_bot/server.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
텔레그램 봇 - Shared Memory IPC + 양방향 명령 채널
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from telegram import Update
|
||||
from telegram.ext import Application, CommandHandler, ContextTypes
|
||||
|
||||
# [디버깅] 파일 로깅 추가
|
||||
log_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))),
|
||||
"telegram_bot.log")
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
|
||||
|
||||
logging.basicConfig(
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
level=logging.INFO,
|
||||
handlers=[logging.StreamHandler(), file_handler]
|
||||
)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class TelegramBotServer:
|
||||
def __init__(self, bot_token, ipc=None, shutdown_event=None):
|
||||
self.application = Application.builder()\
|
||||
.token(bot_token)\
|
||||
.concurrent_updates(True)\
|
||||
.build()
|
||||
|
||||
self.bot_instance = None
|
||||
self.ipc = ipc
|
||||
self.shutdown_event = shutdown_event
|
||||
self.is_shutting_down = False
|
||||
self.should_restart = False
|
||||
|
||||
def set_bot_instance(self, bot):
|
||||
self.bot_instance = bot
|
||||
|
||||
def refresh_bot_instance(self):
|
||||
"""IPC에서 최신 봇 인스턴스 데이터 읽기"""
|
||||
if self.ipc:
|
||||
self.bot_instance = self.ipc.get_bot_instance_data()
|
||||
else:
|
||||
# fallback: 새 IPC 인스턴스 생성
|
||||
from modules.utils.ipc import SharedIPC
|
||||
ipc = SharedIPC()
|
||||
self.bot_instance = ipc.get_bot_instance_data()
|
||||
return self.bot_instance is not None
|
||||
|
||||
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
logging.info(f"[Command] /start from user {update.effective_user.id}")
|
||||
await update.message.reply_text(
|
||||
"<b>AI Trading Bot Command Center</b>\n"
|
||||
"명령어 목록:\n"
|
||||
"/status - 현재 봇 및 시장 상태 조회\n"
|
||||
"/portfolio - 현재 보유 종목 및 평가액\n"
|
||||
"/watchlist - 현재 감시 중인 종목 리스트\n"
|
||||
"/update_watchlist - Watchlist 즉시 업데이트\n"
|
||||
"/macro - 거시경제 지표 및 시장 위험도\n"
|
||||
"/system - PC 리소스(CPU/GPU) 상태\n"
|
||||
"/ai - AI 모델 학습 상태 조회\n"
|
||||
"/evaluate - 즉시 성과 평가 보고서 생성\n\n"
|
||||
"<b>[AI 진단 스킬]</b>\n"
|
||||
"/syshealth - 시스템 종합 건강 진단\n"
|
||||
"/risk - 리스크 대시보드 (MDD, 연속손절)\n"
|
||||
"/regime - 코스피 시장 레짐 감지\n"
|
||||
"/model_health - LSTM 모델 건강 체크\n"
|
||||
"/weights - 앙상블 가중치 분석\n"
|
||||
"/postmortem [일수] - 매매 사후 분석 (기본 30일)\n"
|
||||
"/watchlist_check - 감시 종목 스코어링\n\n"
|
||||
"<b>[관리 명령어]</b>\n"
|
||||
"/restart - 메인 봇 재시작 요청\n"
|
||||
"/exec <code>명령어</code> - 원격 명령어 실행\n"
|
||||
"/stop - 봇 종료",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
logging.info(f"[Command] /status from user {update.effective_user.id}")
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30)
|
||||
|
||||
status_msg = "<b>System Status: ONLINE</b>\n"
|
||||
status_msg += f"<b>Market:</b> {'OPEN' if is_market_open else 'CLOSED'}\n"
|
||||
|
||||
macro_warn = self.bot_instance.is_macro_warning_sent
|
||||
status_msg += f"<b>Macro Filter:</b> {'DANGER (Trading Halted)' if macro_warn else 'SAFE'}\n"
|
||||
|
||||
await update.message.reply_text(status_msg, parse_mode="HTML")
|
||||
|
||||
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("봇 인스턴스가 연결되지 않았습니다.")
|
||||
return
|
||||
|
||||
await update.message.reply_text("잔고를 조회 중입니다...")
|
||||
|
||||
try:
|
||||
balance = self.bot_instance.kis.get_balance()
|
||||
if "error" in balance:
|
||||
await update.message.reply_text(f"잔고 조회 실패: {balance['error']}")
|
||||
return
|
||||
|
||||
msg = f"<b>Total Asset:</b> <code>{int(balance['total_eval']):,} KRW</code>\n" \
|
||||
f"<b>Deposit:</b> <code>{int(balance['deposit']):,} KRW</code>\n\n"
|
||||
|
||||
if balance['holdings']:
|
||||
msg += "<b>[Holdings]</b>\n"
|
||||
for stock in balance['holdings']:
|
||||
yld = float(stock.get('yield', 0))
|
||||
# 상승(빨강), 하락(파랑) 이모지 적용
|
||||
if yld > 0:
|
||||
icon = "🔴"
|
||||
yld_str = f"+{yld}"
|
||||
elif yld < 0:
|
||||
icon = "🔵"
|
||||
yld_str = f"{yld}"
|
||||
else:
|
||||
icon = "⚪"
|
||||
yld_str = f"{yld}"
|
||||
|
||||
msg += f"{icon} <b>{stock['name']}</b>: <code>{yld_str}%</code>\n" \
|
||||
f" (수량: {stock['qty']} / 손익: {stock['profit_loss']:,})\n"
|
||||
else:
|
||||
msg += "보유 중인 종목이 없습니다."
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Error: {str(e)}")
|
||||
|
||||
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("봇 인스턴스가 연결되지 않았습니다.")
|
||||
return
|
||||
|
||||
target_dict = self.bot_instance.load_watchlist()
|
||||
discovered = self.bot_instance.discovered_stocks
|
||||
|
||||
msg = f"<b>Watchlist: {len(target_dict)} items</b>\n"
|
||||
for code, name in target_dict.items():
|
||||
themes = self.bot_instance.theme_manager.get_themes(code)
|
||||
theme_str = f" ({', '.join(themes)})" if themes else ""
|
||||
msg += f"• <b>{name}</b>{theme_str}\n"
|
||||
|
||||
if discovered:
|
||||
msg += f"\n<b>Discovered Today ({len(discovered)}):</b>\n"
|
||||
for code in discovered:
|
||||
msg += f"- {code}\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Watchlist 업데이트 - command queue를 통해 메인 봇에 요청"""
|
||||
if self.ipc and self.ipc.send_command('update_watchlist'):
|
||||
await update.message.reply_text("Watchlist 업데이트를 메인 봇에 요청했습니다.")
|
||||
else:
|
||||
# fallback: 직접 업데이트
|
||||
await update.message.reply_text("Watchlist를 업데이트하고 있습니다... (30초 소요)")
|
||||
try:
|
||||
from modules.services.kis import KISClient
|
||||
from watchlist_manager import WatchlistManager
|
||||
from modules.config import Config
|
||||
|
||||
temp_kis = KISClient()
|
||||
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
|
||||
summary = mgr.update_watchlist_daily()
|
||||
summary = summary.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
await update.message.reply_text(summary)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"업데이트 실패: {e}")
|
||||
|
||||
async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇 연결 대기 중...")
|
||||
return
|
||||
|
||||
await update.message.reply_text("거시경제 데이터를 불러옵니다...")
|
||||
|
||||
try:
|
||||
indices = getattr(self.bot_instance.kis, '_macro_indices', {})
|
||||
|
||||
if not indices:
|
||||
await update.message.reply_text("데이터가 아직 수집되지 않았습니다.")
|
||||
return
|
||||
|
||||
msi = float(indices.get('MSI', 0))
|
||||
if msi >= 50:
|
||||
risk_status = "🔴 DANGER"
|
||||
risk_desc = "시장 극도 불안정 - 매수 중단 권고"
|
||||
elif msi >= 30:
|
||||
risk_status = "🟡 CAUTION"
|
||||
risk_desc = "시장 불안정 - 보수적 매매 권고"
|
||||
else:
|
||||
risk_status = "🟢 SAFE"
|
||||
risk_desc = "시장 안정 - 정상 매매 가능"
|
||||
|
||||
from datetime import datetime
|
||||
now_str = datetime.now().strftime("%m/%d %H:%M")
|
||||
|
||||
msg = f"<b>거시경제 지표</b> <code>{now_str}</code>\n"
|
||||
msg += f"━━━━━━━━━━━━━━━━━━\n"
|
||||
msg += f"<b>Market Risk:</b> {risk_status}\n"
|
||||
msg += f"<i>{risk_desc}</i>\n\n"
|
||||
|
||||
# MSI 상세
|
||||
msi_bar = "█" * int(msi / 10) + "░" * (10 - int(msi / 10))
|
||||
msg += f"<b>Stress Index (MSI):</b> <code>{msi:.1f}/100</code>\n"
|
||||
msg += f"<code>[{msi_bar}]</code>\n\n"
|
||||
|
||||
# 지수 상세
|
||||
index_order = ["KOSPI", "KOSDAQ", "KOSPI200"]
|
||||
for k in index_order:
|
||||
if k not in indices:
|
||||
continue
|
||||
v = indices[k]
|
||||
price = float(v.get('price', 0))
|
||||
change = float(v.get('change', 0))
|
||||
change_val = float(v.get('change_val', 0))
|
||||
high = float(v.get('high', 0))
|
||||
low = float(v.get('low', 0))
|
||||
prev_close = float(v.get('prev_close', 0))
|
||||
volume = int(v.get('volume', 0))
|
||||
|
||||
if price == 0:
|
||||
# 장 마감 후: prev_close(전일 종가)라도 표시
|
||||
if prev_close > 0:
|
||||
msg += f"⚫ <b>{k}:</b> <code>{prev_close:,.2f}</code> <i>(전일 종가 기준, 장 마감)</i>\n\n"
|
||||
else:
|
||||
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
|
||||
continue
|
||||
|
||||
if change > 0:
|
||||
icon = "🔴"
|
||||
chg_str = f"+{change:.2f}% (+{change_val:.2f}pt)"
|
||||
elif change < 0:
|
||||
icon = "🔵"
|
||||
chg_str = f"{change:.2f}% ({change_val:.2f}pt)"
|
||||
else:
|
||||
icon = "⚪"
|
||||
chg_str = f"{change:.2f}%"
|
||||
|
||||
msg += f"{icon} <b>{k}:</b> <code>{price:,.2f}</code> {chg_str}\n"
|
||||
if high and low:
|
||||
msg += f" 고: <code>{high:,.2f}</code> 저: <code>{low:,.2f}</code>"
|
||||
if prev_close:
|
||||
msg += f" 전일종가: <code>{prev_close:,.2f}</code>"
|
||||
msg += "\n"
|
||||
if volume:
|
||||
msg += f" 거래량: <code>{volume:,}천주</code>\n"
|
||||
msg += "\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"Error: {e}")
|
||||
|
||||
async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
import psutil
|
||||
|
||||
# non-blocking CPU 측정
|
||||
cpu = psutil.cpu_percent(interval=0)
|
||||
ram = psutil.virtual_memory().percent
|
||||
|
||||
top_processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
|
||||
try:
|
||||
proc_info = proc.info
|
||||
if proc_info['name'] == 'System Idle Process':
|
||||
continue
|
||||
top_processes.append(proc_info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
|
||||
top_processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True)
|
||||
top_3 = top_processes[:3]
|
||||
|
||||
gpu_status = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||
gpu_msg = "N/A"
|
||||
if gpu_status and gpu_status.get('name') != 'N/A':
|
||||
gpu_name = gpu_status.get('name', 'GPU')
|
||||
gpu_msg = f"{gpu_name}\n Temp: {gpu_status.get('temp', 0)}C / " \
|
||||
f"VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB"
|
||||
|
||||
msg = "<b>PC System Status</b>\n" \
|
||||
f"<b>CPU:</b> <code>{cpu}%</code>\n" \
|
||||
f"<b>RAM:</b> <code>{ram}%</code>\n" \
|
||||
f"<b>GPU:</b> {gpu_msg}\n\n"
|
||||
|
||||
if top_3:
|
||||
msg += "<b>Top CPU Processes:</b>\n"
|
||||
for i, proc in enumerate(top_3, 1):
|
||||
proc_name = proc.get('name', 'Unknown')
|
||||
proc_cpu = proc.get('cpu_percent', 0)
|
||||
msg += f" {i}. <code>{proc_name}</code> - {proc_cpu:.1f}%\n"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if not self.refresh_bot_instance():
|
||||
await update.message.reply_text("메인 봇이 실행 중이 아닙니다.")
|
||||
return
|
||||
|
||||
from modules.config import Config
|
||||
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||
|
||||
if Config.GEMINI_API_KEY:
|
||||
llm_primary = f"Gemini ({Config.GEMINI_MODEL})"
|
||||
llm_fallback = f"Ollama ({Config.OLLAMA_MODEL})"
|
||||
else:
|
||||
llm_primary = f"Ollama ({Config.OLLAMA_MODEL})"
|
||||
llm_fallback = None
|
||||
|
||||
msg = "<b>AI Model Status</b>\n"
|
||||
msg += f"* <b>LLM Engine:</b> {llm_primary}\n"
|
||||
if llm_fallback:
|
||||
msg += f"* <b>Fallback:</b> {llm_fallback}\n"
|
||||
msg += f"* <b>LSTM Device:</b> {gpu.get('name', 'GPU')}\n"
|
||||
|
||||
if gpu:
|
||||
msg += f"* <b>GPU Load:</b> <code>{gpu.get('load', 0)}%</code>\n"
|
||||
msg += f"* <b>VRAM Usage:</b> <code>{gpu.get('vram_used', 0)}GB</code> / {gpu.get('vram_total', 0)}GB"
|
||||
|
||||
await update.message.reply_text(msg, parse_mode="HTML")
|
||||
|
||||
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/restart: 메인 봇에 재시작 명령 전달"""
|
||||
if self.ipc and self.ipc.send_command('restart'):
|
||||
await update.message.reply_text(
|
||||
"<b>메인 봇에 재시작 요청을 전송했습니다.</b>", parse_mode="HTML")
|
||||
else:
|
||||
# IPC 명령 실패 시 텔레그램 봇만 재시작
|
||||
await update.message.reply_text(
|
||||
"<b>텔레그램 인터페이스를 재시작합니다...</b>", parse_mode="HTML")
|
||||
self.should_restart = True
|
||||
self.application.stop_running()
|
||||
|
||||
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(
|
||||
"<b>텔레그램 봇을 종료합니다.</b>", parse_mode="HTML")
|
||||
self.should_restart = False
|
||||
self.application.stop_running()
|
||||
|
||||
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
text = update.message.text.strip()
|
||||
parts = text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
await update.message.reply_text("사용법: /exec 명령어")
|
||||
return
|
||||
|
||||
command = parts[1]
|
||||
await update.message.reply_text(f"실행 중: <code>{command}</code>", parse_mode="HTML")
|
||||
|
||||
try:
|
||||
dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot']
|
||||
if any(keyword in command.lower() for keyword in dangerous_keywords):
|
||||
await update.message.reply_text("위험한 명령어는 실행할 수 없습니다.")
|
||||
return
|
||||
|
||||
import platform
|
||||
is_windows = platform.system() == 'Windows'
|
||||
|
||||
if is_windows:
|
||||
exec_cmd = ['powershell', '-Command', command]
|
||||
else:
|
||||
exec_cmd = command
|
||||
|
||||
def run_subprocess():
|
||||
return subprocess.run(
|
||||
exec_cmd,
|
||||
shell=not is_windows,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
timeout=30,
|
||||
cwd=os.getcwd()
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, run_subprocess)
|
||||
|
||||
output = result.stdout.strip() if result.stdout else ""
|
||||
error_output = result.stderr.strip() if result.stderr else ""
|
||||
|
||||
if output and error_output:
|
||||
combined = f"[STDOUT]\n{output}\n\n[STDERR]\n{error_output}"
|
||||
elif output:
|
||||
combined = output
|
||||
elif error_output:
|
||||
combined = f"[ERROR]\n{error_output}"
|
||||
else:
|
||||
combined = "명령어 실행 완료 (출력 없음)"
|
||||
|
||||
if len(combined) > 3000:
|
||||
combined = combined[:3000] + "\n... (Truncated)"
|
||||
|
||||
combined = combined.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
await update.message.reply_text(f"<pre>{combined}</pre>", parse_mode="HTML")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await update.message.reply_text("명령어 실행 시간 초과 (30초)")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"실행 오류: {e}")
|
||||
|
||||
async def evaluate_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/evaluate: 즉시 성과 평가 보고서 생성 (LLM 분석 포함)"""
|
||||
await update.message.reply_text(
|
||||
"📊 성과 평가를 실행합니다...\n"
|
||||
"<i>LLM 전문가 패널 분석 포함 시 30초~1분 소요됩니다.</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
try:
|
||||
from modules.utils.performance_db import PerformanceDB
|
||||
from modules.analysis.evaluator import PerformanceEvaluator
|
||||
|
||||
evaluator = PerformanceEvaluator()
|
||||
loop = asyncio.get_running_loop()
|
||||
report = await loop.run_in_executor(None, evaluator.generate_weekly_report)
|
||||
|
||||
if len(report) > 4000:
|
||||
report = report[:4000] + "\n... (일부 생략)"
|
||||
|
||||
await update.message.reply_text(report, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /evaluate error: {e}")
|
||||
await update.message.reply_text(f"평가 오류: {e}")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# AI 진단 스킬 명령어 (skill_runner 기반)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
async def syshealth_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/syshealth: 시스템 종합 건강 진단"""
|
||||
await update.message.reply_text("🔍 시스템 건강 진단 중... (최대 30초 소요)", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
result = await skill_runner.run_syshealth()
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /syshealth error: {e}")
|
||||
await update.message.reply_text(f"진단 오류: {e}")
|
||||
|
||||
async def risk_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/risk: 리스크 대시보드 (MDD, 연속손절, 포지션 집중도)"""
|
||||
await update.message.reply_text("📊 리스크 데이터 분석 중...", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
result = await skill_runner.run_risk()
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /risk error: {e}")
|
||||
await update.message.reply_text(f"리스크 분석 오류: {e}")
|
||||
|
||||
async def regime_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/regime: 코스피 시장 레짐 감지"""
|
||||
await update.message.reply_text("📈 시장 레짐 분석 중...", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
result = await skill_runner.run_regime()
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /regime error: {e}")
|
||||
await update.message.reply_text(f"레짐 분석 오류: {e}")
|
||||
|
||||
async def model_health_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/model_health: LSTM 모델 건강 체크"""
|
||||
await update.message.reply_text("🧠 LSTM 모델 체크포인트 스캔 중...", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
result = await skill_runner.run_model_health()
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /model_health error: {e}")
|
||||
await update.message.reply_text(f"모델 건강 체크 오류: {e}")
|
||||
|
||||
async def weights_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/weights: 앙상블 가중치 분석"""
|
||||
await update.message.reply_text("⚖️ 앙상블 가중치 분석 중...", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
result = await skill_runner.run_weights()
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /weights error: {e}")
|
||||
await update.message.reply_text(f"가중치 분석 오류: {e}")
|
||||
|
||||
async def postmortem_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/postmortem [days]: 매매 사후 분석 (기본 30일)"""
|
||||
args = context.args
|
||||
days = 30
|
||||
if args:
|
||||
try:
|
||||
days = int(args[0])
|
||||
days = max(7, min(days, 365))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
await update.message.reply_text(
|
||||
f"🔬 최근 {days}일 매매 사후 분석 중...", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
result = await skill_runner.run_postmortem(days)
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /postmortem error: {e}")
|
||||
await update.message.reply_text(f"사후 분석 오류: {e}")
|
||||
|
||||
async def watchlist_check_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""/watchlist_check: 현재 감시 종목 스코어링"""
|
||||
await update.message.reply_text("🔎 감시 종목 스코어링 중...", parse_mode="HTML")
|
||||
try:
|
||||
from modules.services.telegram_bot import skill_runner
|
||||
|
||||
# 현재 watchlist에서 종목 코드 목록 로드
|
||||
candidates = []
|
||||
try:
|
||||
import json, os
|
||||
from modules.config import Config
|
||||
wl_path = Config.WATCHLIST_FILE
|
||||
if os.path.exists(wl_path):
|
||||
with open(wl_path, encoding="utf-8") as f:
|
||||
wl_data = json.load(f)
|
||||
if isinstance(wl_data, dict):
|
||||
candidates = list(wl_data.keys())
|
||||
elif isinstance(wl_data, list):
|
||||
candidates = wl_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = await skill_runner.run_watchlist_check(candidates)
|
||||
for chunk in result:
|
||||
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(f"[Command] /watchlist_check error: {e}")
|
||||
await update.message.reply_text(f"스코어링 오류: {e}")
|
||||
|
||||
def run(self):
|
||||
handlers = [
|
||||
("start", self.start_command),
|
||||
("status", self.status_command),
|
||||
("portfolio", self.portfolio_command),
|
||||
("watchlist", self.watchlist_command),
|
||||
("update_watchlist", self.update_watchlist_command),
|
||||
("macro", self.macro_command),
|
||||
("system", self.system_command),
|
||||
("ai", self.ai_status_command),
|
||||
("evaluate", self.evaluate_command),
|
||||
("syshealth", self.syshealth_command),
|
||||
("risk", self.risk_command),
|
||||
("regime", self.regime_command),
|
||||
("model_health", self.model_health_command),
|
||||
("weights", self.weights_command),
|
||||
("postmortem", self.postmortem_command),
|
||||
("watchlist_check", self.watchlist_check_command),
|
||||
("restart", self.restart_command),
|
||||
("stop", self.stop_command),
|
||||
("exec", self.exec_command)
|
||||
]
|
||||
|
||||
for cmd, func in handlers:
|
||||
self.application.add_handler(CommandHandler(cmd, func))
|
||||
|
||||
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if "Conflict" in str(context.error):
|
||||
print(f"[Telegram] Conflict detected. Stopping...")
|
||||
if self.application.running:
|
||||
await self.application.stop()
|
||||
return
|
||||
print(f"[Telegram Error] {context.error}")
|
||||
|
||||
self.application.add_error_handler(error_handler)
|
||||
|
||||
logging.info("[Telegram] Command Server Started (Shared Memory IPC Mode).")
|
||||
print("[Telegram] Command Server Started (Shared Memory IPC Mode).")
|
||||
|
||||
try:
|
||||
self.application.run_polling(
|
||||
allowed_updates=Update.ALL_TYPES,
|
||||
drop_pending_updates=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Telegram] Polling Error: {e}")
|
||||
463
signal_v1/modules/services/telegram_bot/skill_runner.py
Normal file
463
signal_v1/modules/services/telegram_bot/skill_runner.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
Skill Runner — 텔레그램 봇에서 Claude Skills 스크립트를 실행하는 유틸리티
|
||||
|
||||
각 스킬 스크립트를 subprocess로 실행하고, 결과를 텔레그램 HTML 메시지로 포맷합니다.
|
||||
Claude Code 없이도 텔레그램 명령어만으로 분석 리포트를 받을 수 있습니다.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 봇 프로젝트 루트 (이 파일 기준 3단계 상위)
|
||||
BOT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
SKILLS_DIR = BOT_ROOT / ".claude" / "skills"
|
||||
PYTHON_EXE = sys.executable # 현재 봇과 동일한 Python 인터프리터 사용
|
||||
|
||||
|
||||
def _skill_script(skill_name: str, script_name: str) -> Path:
|
||||
return SKILLS_DIR / skill_name / "scripts" / script_name
|
||||
|
||||
|
||||
async def _run_script(script_path: Path, extra_args: Optional[list] = None,
|
||||
timeout: int = 60) -> dict:
|
||||
"""
|
||||
스킬 스크립트를 비동기 subprocess로 실행.
|
||||
--bot-path, --json 플래그를 자동으로 추가.
|
||||
반환: {"ok": bool, "output": str, "json_data": dict|None}
|
||||
"""
|
||||
if not script_path.exists():
|
||||
return {"ok": False, "output": f"스크립트 없음: {script_path}", "json_data": None}
|
||||
|
||||
cmd = [PYTHON_EXE, str(script_path),
|
||||
"--bot-path", str(BOT_ROOT),
|
||||
"--json"]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# PYTHONIOENCODING=utf-8: 서브프로세스 stdout에서 유니코드/이모지 출력 허용
|
||||
_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
cwd=str(BOT_ROOT),
|
||||
env=_env,
|
||||
)
|
||||
)
|
||||
|
||||
raw_out = result.stdout.strip()
|
||||
raw_err = result.stderr.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
json_data = None
|
||||
if raw_out:
|
||||
try:
|
||||
json_data = json.loads(raw_out)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if result.returncode != 0 and not raw_out:
|
||||
return {"ok": False, "output": raw_err or "알 수 없는 오류", "json_data": None}
|
||||
|
||||
return {"ok": True, "output": raw_out, "json_data": json_data}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "output": f"실행 시간 초과 ({timeout}초)", "json_data": None}
|
||||
except Exception as e:
|
||||
return {"ok": False, "output": str(e), "json_data": None}
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 3800) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "\n<i>... (일부 생략)</i>"
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 스킬별 포맷터
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _fmt_syshealth(data: dict) -> str:
|
||||
ipc = data.get("ipc", {})
|
||||
gpu = data.get("gpu", {})
|
||||
token = data.get("kis_token", {})
|
||||
procs = data.get("processes", {})
|
||||
|
||||
ipc_status = ipc.get("status", "?")
|
||||
ipc_emoji = {"FRESH": "✅", "NORMAL": "✅", "STALE": "⚠️",
|
||||
"EXPIRED": "🔴", "EMPTY": "⚠️", "ERROR": "🔴"}.get(ipc_status, "❓")
|
||||
age = ipc.get("age_seconds")
|
||||
age_str = f"{age}초 전" if age is not None else "알 수 없음"
|
||||
|
||||
api_str = "✅ 실행 중" if procs.get("api_running") else "🔴 오프라인"
|
||||
token_str = "✅ 유효" if token.get("status") == "VALID" else f"🔴 {token.get('status','?')}"
|
||||
token_env = token.get("env", "?")
|
||||
|
||||
vram = gpu.get("vram_used_gb")
|
||||
vram_str = f"{vram}GB / {gpu.get('vram_total_gb', 16)}GB" if vram else "측정 불가"
|
||||
cuda_str = "✅" if gpu.get("cuda_available") else "❌"
|
||||
|
||||
# 로그 에러 집계
|
||||
logs = data.get("logs", {})
|
||||
all_errors = {}
|
||||
for ld in logs.values():
|
||||
for k, v in ld.get("errors", {}).items():
|
||||
all_errors[k] = all_errors.get(k, 0) + v
|
||||
err_lines = "\n".join(
|
||||
f" ⚠️ {k}: {v}회" for k, v in sorted(all_errors.items(), key=lambda x: x[1], reverse=True)
|
||||
) or " ✅ 없음"
|
||||
|
||||
balance = ipc.get("balance")
|
||||
balance_str = f"\n 잔고: <code>{int(balance):,}원</code>" if balance else ""
|
||||
wl_count = ipc.get("watchlist_count", 0)
|
||||
|
||||
msg = (
|
||||
f"<b>🔧 시스템 헬스 진단</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>API 서버:</b> {api_str}\n"
|
||||
f"<b>IPC 상태:</b> {ipc_emoji} {ipc_status} ({age_str})"
|
||||
f"{balance_str}\n"
|
||||
f" 감시종목: {wl_count}개\n"
|
||||
f"<b>GPU/CUDA:</b> {cuda_str} VRAM: <code>{vram_str}</code>\n"
|
||||
f"<b>KIS 토큰:</b> {token_str} ({token_env})\n\n"
|
||||
f"<b>로그 에러 (최근):</b>\n{err_lines}"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_risk(data: dict) -> str:
|
||||
mdd = data.get("mdd", {})
|
||||
dl = data.get("daily_loss", {})
|
||||
cl = data.get("consecutive_losses", {})
|
||||
cap = data.get("total_capital", 0)
|
||||
|
||||
mdd_val = mdd.get("mdd", 0) or 0
|
||||
mdd_emoji = "✅" if mdd_val > -5 else ("⚠️" if mdd_val > -10 else "🔴")
|
||||
|
||||
dl_ratio = dl.get("ratio", 0) or 0
|
||||
dl_emoji = "✅" if dl_ratio < 50 else ("⚠️" if dl_ratio < 75 else "🔴")
|
||||
|
||||
cl_count = cl.get("count", 0)
|
||||
cl_active = cl.get("cooldown_active", False)
|
||||
cl_emoji = "🚨" if cl_active else ("⚠️" if cl_count >= 2 else "✅")
|
||||
|
||||
msg = (
|
||||
f"<b>🛡️ 리스크 대시보드</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>총 자산:</b> <code>{int(cap):,}원</code>\n\n"
|
||||
f"<b>MDD:</b> {mdd_emoji} <code>{mdd_val:.1f}%</code> ({mdd.get('level','?')})\n"
|
||||
f" 최고점: <code>{int(mdd.get('peak',0) or 0):,}원</code> ({mdd.get('peak_days_ago','?')}일 전)\n"
|
||||
f" 복구 필요: <code>+{mdd.get('recovery_needed',0):.1f}%</code>\n\n"
|
||||
f"<b>일일 손실한도:</b> {dl_emoji} {dl_ratio:.0f}% 소진\n"
|
||||
f" 한도: <code>{int(dl.get('limit',0) or 0):,}원</code> "
|
||||
f"사용: <code>{int(dl.get('used',0) or 0):,}원</code>\n\n"
|
||||
f"<b>연속 손절:</b> {cl_emoji} {cl_count}회"
|
||||
)
|
||||
if cl_active:
|
||||
msg += f"\n 🚨 매수 중단 중 (재개: {cl.get('resume_time','?')})"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_regime(data: dict) -> str:
|
||||
regime = data.get("regime", "?")
|
||||
msi = data.get("msi", {})
|
||||
params = data.get("recommended_params", {})
|
||||
ens = params.get("ensemble", {})
|
||||
data_source = data.get("data_source", "ipc")
|
||||
source_note = " <i>(IPC 데이터 없음 — 기본값 기반)</i>\n" if data_source == "default" else ""
|
||||
|
||||
regime_emoji = {
|
||||
"BULL_EXTREME": "🔥", "BULL_STRONG": "📈",
|
||||
"NORMAL": "➡️", "BEAR_WEAK": "📉", "BEAR_STRONG": "🚨"
|
||||
}.get(regime, "❓")
|
||||
status_emoji = {"SAFE": "✅", "CAUTION": "⚠️", "DANGER": "🚨"}.get(msi.get("status", ""), "❓")
|
||||
|
||||
flags = msi.get("flags", {})
|
||||
flag_lines = "\n".join(f" {v}" for v in flags.values())
|
||||
|
||||
msg = (
|
||||
f"<b>📊 시장 레짐 분석</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"{source_note}"
|
||||
f"<b>레짐:</b> {regime_emoji} {regime}\n"
|
||||
f"<b>MSI:</b> {status_emoji} {msi.get('score','?')}/{msi.get('max','?')} ({msi.get('status','?')})\n\n"
|
||||
f"<b>지표 현황:</b>\n{flag_lines}\n\n"
|
||||
f"<b>권고 파라미터:</b>\n"
|
||||
f" buy_threshold: <code>{params.get('buy_threshold','?')}</code>\n"
|
||||
f" max_position: <code>{params.get('max_position_ratio','?')}</code>\n"
|
||||
f" sl_atr_mult: <code>{params.get('sl_atr_multiplier','?')}</code>\n\n"
|
||||
f"<b>앙상블 권고:</b>\n"
|
||||
f" tech: <code>{ens.get('tech','?')}</code> "
|
||||
f"lstm: <code>{ens.get('lstm','?')}</code> "
|
||||
f"sent: <code>{ens.get('sentiment','?')}</code>\n"
|
||||
f"<i>다음 점검: {params.get('next_check_days','?')}일 후</i>"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_model_health(data: dict) -> str:
|
||||
models = data.get("models", {})
|
||||
missing = data.get("missing_models", [])
|
||||
|
||||
grade_emoji = {"HEALTHY": "🟢", "WARNING": "🟡", "DEGRADED": "🟠",
|
||||
"CRITICAL": "🔴", "MISSING": "⚫"}
|
||||
grade_counts = {}
|
||||
for info in models.values():
|
||||
g = info.get("grade", "?")
|
||||
grade_counts[g] = grade_counts.get(g, 0) + 1
|
||||
|
||||
# 우선순위 높은 종목 상위 5개
|
||||
critical = [(t, i) for t, i in models.items() if i.get("grade") in ("CRITICAL", "DEGRADED")]
|
||||
critical.sort(key=lambda x: {"CRITICAL": 0, "DEGRADED": 1}.get(x[1].get("grade"), 9))
|
||||
|
||||
summary_lines = "\n".join(
|
||||
f" {grade_emoji.get(g,'?')} {g}: {cnt}개"
|
||||
for g, cnt in grade_counts.items()
|
||||
)
|
||||
critical_lines = ""
|
||||
for t, info in critical[:5]:
|
||||
critical_lines += f"\n {grade_emoji.get(info['grade'],'?')} {t}: {info.get('reason','?')}"
|
||||
|
||||
missing_str = ""
|
||||
if missing:
|
||||
missing_str = f"\n\n<b>모델 없는 감시종목:</b>\n " + ", ".join(missing[:5])
|
||||
if len(missing) > 5:
|
||||
missing_str += f" 외 {len(missing)-5}개"
|
||||
|
||||
msg = (
|
||||
f"<b>🤖 LSTM 모델 건강도</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>체크포인트 {len(models)}개:</b>\n"
|
||||
f"{summary_lines}"
|
||||
)
|
||||
if critical_lines:
|
||||
msg += f"\n\n<b>조치 필요:</b>{critical_lines}"
|
||||
msg += missing_str
|
||||
if not critical and not missing:
|
||||
msg += "\n\n✅ 모든 모델 정상"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_weights(data: dict) -> str:
|
||||
current = data.get("current_global", {})
|
||||
optimal = data.get("optimal_global", {})
|
||||
health = data.get("ema_health", {})
|
||||
contribs = data.get("signal_contributions", {})
|
||||
|
||||
issues = "\n".join(f" {i}" for i in health.get("issues", []))
|
||||
health_status = "✅" if health.get("status") == "OK" else "⚠️"
|
||||
|
||||
contrib_lines = ""
|
||||
for sig, c in contribs.items():
|
||||
if c.get("total_trades", 0) > 0:
|
||||
acc = c.get("accuracy", 0)
|
||||
contrib_lines += f"\n {sig}: 정확도 {acc:.1%} ({c['total_trades']}거래)"
|
||||
|
||||
delta_lines = ""
|
||||
for sig in ["tech", "lstm", "sentiment"]:
|
||||
cur = current.get(sig, 0)
|
||||
opt = optimal.get(sig, cur)
|
||||
diff = round(opt - cur, 3)
|
||||
arrow = "↑" if diff > 0 else ("↓" if diff < 0 else "→")
|
||||
delta_lines += f"\n {sig:12s}: {cur} {arrow} <b>{opt}</b>"
|
||||
|
||||
msg = (
|
||||
f"<b>⚖️ 앙상블 가중치</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>EMA 학습 상태:</b> {health_status}\n{issues}\n"
|
||||
)
|
||||
if contrib_lines:
|
||||
msg += f"\n<b>신호 기여도:</b>{contrib_lines}\n"
|
||||
msg += f"\n<b>권고 조정:</b>{delta_lines}"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_postmortem(data: dict) -> str:
|
||||
stats = data.get("basic_stats", {})
|
||||
combos = data.get("signal_combinations", {})
|
||||
suggestions = data.get("parameter_suggestions", {})
|
||||
days = data.get("days", 30)
|
||||
|
||||
wr = stats.get("win_rate", 0)
|
||||
pr = stats.get("profit_ratio", 0)
|
||||
wr_emoji = "✅" if wr >= 55 else ("⚠️" if wr >= 50 else "🔴")
|
||||
pr_emoji = "✅" if pr >= 2.0 else ("⚠️" if pr >= 1.5 else "🔴")
|
||||
|
||||
best_combos = list(combos.items())[:2]
|
||||
worst_combos = list(combos.items())[-2:]
|
||||
|
||||
combo_lines = ""
|
||||
for k, v in best_combos:
|
||||
combo_lines += f"\n ✅ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
|
||||
for k, v in worst_combos:
|
||||
if v["win_rate"] < 50:
|
||||
combo_lines += f"\n ⚠️ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
|
||||
|
||||
suggest_lines = ""
|
||||
for param, s in suggestions.items():
|
||||
suggest_lines += f"\n {param}: {s.get('current','?')} → <b>{s.get('recommended','?')}</b>"
|
||||
|
||||
msg = (
|
||||
f"<b>📊 매매 사후분석</b> (최근 {days}일)\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>총 거래:</b> {stats.get('total',0)}건 "
|
||||
f"승률: {wr_emoji} <code>{wr}%</code>\n"
|
||||
f"<b>손익비:</b> {pr_emoji} <code>{pr}</code> "
|
||||
f"Sharpe: <code>{stats.get('sharpe',0)}</code>\n"
|
||||
f"평균 수익: <code>+{stats.get('avg_win_pct',0)}%</code> "
|
||||
f"평균 손실: <code>-{stats.get('avg_loss_pct',0)}%</code>"
|
||||
)
|
||||
if combo_lines:
|
||||
msg += f"\n\n<b>신호 조합:</b>{combo_lines}"
|
||||
if suggest_lines:
|
||||
msg += f"\n\n<b>파라미터 권고:</b>{suggest_lines}"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_watchlist(data: dict) -> str:
|
||||
scored = data.get("scored", [])
|
||||
current = data.get("current_watchlist", [])
|
||||
r_min, r_max = data.get("recommended_range", (8, 15))
|
||||
|
||||
to_add = [s for s in scored if s.get("action") == "편입"]
|
||||
to_remove = [s for s in scored if s.get("action") == "제거"]
|
||||
to_keep = [s for s in scored if s.get("action") == "유지" and s.get("in_watchlist")]
|
||||
to_keep.sort(key=lambda x: x.get("total_score", 0), reverse=True)
|
||||
|
||||
add_lines = ""
|
||||
for s in to_add[:5]:
|
||||
wr = f" ({s['win_rate']:.0%})" if s.get("win_rate") else ""
|
||||
add_lines += f"\n ✅ {s['ticker']} {s['total_score']}점 — {s.get('theme','?')}{wr}"
|
||||
|
||||
remove_lines = ""
|
||||
for s in to_remove:
|
||||
remove_lines += f"\n ✕ {s['ticker']} {s['total_score']}점"
|
||||
|
||||
keep_lines = ""
|
||||
for s in to_keep[:3]:
|
||||
keep_lines += f"\n • {s['ticker']} {s['total_score']}점"
|
||||
|
||||
final = len(current) - len(to_remove) + len(to_add)
|
||||
size_ok = "✅" if r_min <= final <= r_max else "⚠️"
|
||||
|
||||
msg = (
|
||||
f"<b>📋 Watchlist 분석</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"현재 {len(current)}종목 → 최종 {final}종목 {size_ok}\n"
|
||||
f"권고 규모: {r_min}~{r_max}종목"
|
||||
)
|
||||
if add_lines:
|
||||
msg += f"\n\n<b>편입 추천:</b>{add_lines}"
|
||||
if remove_lines:
|
||||
msg += f"\n\n<b>제거 추천:</b>{remove_lines}"
|
||||
if keep_lines:
|
||||
msg += f"\n\n<b>상위 유지 종목:</b>{keep_lines}"
|
||||
return msg
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 공개 API — 텔레그램 핸들러에서 호출
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _to_chunks(text: str, limit: int = 3800) -> List[str]:
|
||||
"""메시지가 Telegram 4096자 제한을 초과하면 청크로 분할"""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks = []
|
||||
while text:
|
||||
chunks.append(text[:limit])
|
||||
text = text[limit:]
|
||||
return chunks
|
||||
|
||||
|
||||
async def run_syshealth() -> List[str]:
|
||||
script = _skill_script("bot-system-health-diagnostics", "health_checker.py")
|
||||
r = await _run_script(script, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 시스템 헬스 실행 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_syshealth(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_risk() -> List[str]:
|
||||
script = _skill_script("auto-trade-risk-manager", "risk_dashboard.py")
|
||||
r = await _run_script(script, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 리스크 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_risk(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_regime() -> List[str]:
|
||||
script = _skill_script("korean-market-regime-detector", "regime_calculator.py")
|
||||
r = await _run_script(script, timeout=60)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 레짐 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_regime(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_model_health() -> List[str]:
|
||||
script = _skill_script("lstm-model-health-monitor", "model_health_report.py")
|
||||
r = await _run_script(script, timeout=60)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 모델 건강도 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_model_health(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_weights() -> List[str]:
|
||||
script = _skill_script("ensemble-weight-optimizer", "weight_optimizer.py")
|
||||
r = await _run_script(script, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 가중치 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_weights(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_postmortem(days: int = 30) -> List[str]:
|
||||
script = _skill_script("trade-post-mortem-analyzer", "post_mortem_report.py")
|
||||
r = await _run_script(script, extra_args=["--days", str(days)], timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 매매 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_postmortem(r["json_data"]))
|
||||
if not r["output"].strip():
|
||||
return [f"<b>📊 매매 사후분석</b> (최근 {days}일)\n━━━━━━━━━━━━━━━━━━\n<i>분석 대상 매매 기록이 없습니다.</i>"]
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_watchlist_check(candidates: Optional[List[str]] = None) -> List[str]:
|
||||
script = _skill_script("watchlist-intelligence-curator", "watchlist_scorer.py")
|
||||
extra = []
|
||||
if candidates:
|
||||
extra = ["--candidates"] + candidates
|
||||
r = await _run_script(script, extra_args=extra, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ Watchlist 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_watchlist(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
130
signal_v1/modules/strategy/daily_ledger.py
Normal file
130
signal_v1/modules/strategy/daily_ledger.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
일일 거래 장부 (DailyLedger) — v3.2
|
||||
|
||||
bot.py에 흩어져 있던 당일 상태를 한 객체로 집약:
|
||||
- 당일 누적 매수금액 (KIS T+2 미차감 보완용)
|
||||
- 연속 손절 카운터 + 매수 일시중단 타이머
|
||||
- 미매도 종목의 매수 신호 점수 (앙상블 학습용)
|
||||
- 일별 스냅샷/주간평가 플래그
|
||||
|
||||
날짜가 바뀌면 reset_if_new_day()가 자동 초기화.
|
||||
순수 객체로 구현 — 외부 I/O 없음 → 단위 테스트 가능.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, date as date_cls
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyLedger:
|
||||
# ── 당일 매수 회계 ──
|
||||
today_buy_total: int = 0
|
||||
today_buy_date: Optional[date_cls] = None
|
||||
|
||||
# ── 연속 손절 / 매수 일시 중단 ──
|
||||
consecutive_stop_losses: int = 0
|
||||
buy_paused_until: Optional[datetime] = None
|
||||
stop_loss_pause_threshold: int = 3
|
||||
stop_loss_pause_minutes: int = 30
|
||||
|
||||
# ── 앙상블 학습용: 미매도 종목의 매수 신호 점수 ──
|
||||
buy_scores: Dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# ── 일일 플래그 ──
|
||||
snapshot_taken: bool = False
|
||||
weekly_eval_sent: bool = False
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 날짜 전환
|
||||
# ──────────────────────────────────────────────
|
||||
def reset_if_new_day(self, now: datetime) -> bool:
|
||||
"""
|
||||
오늘 날짜 기준으로 상태 초기화. 이미 오늘 자로 초기화됐으면 no-op.
|
||||
|
||||
Returns:
|
||||
True — 실제로 초기화를 수행한 경우
|
||||
False — 같은 날이라 그대로 둔 경우
|
||||
"""
|
||||
today = now.date()
|
||||
if self.today_buy_date == today:
|
||||
return False
|
||||
self.today_buy_total = 0
|
||||
self.today_buy_date = today
|
||||
self.buy_scores.clear()
|
||||
self.snapshot_taken = False
|
||||
self.weekly_eval_sent = False
|
||||
# 연속 손절 카운터 / 일시중단 타이머는 날짜 전환 시에만 초기화
|
||||
self.consecutive_stop_losses = 0
|
||||
self.buy_paused_until = None
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 매수 / 매도 기록
|
||||
# ──────────────────────────────────────────────
|
||||
def record_buy(self, ticker: str, amount: int, scores: dict) -> None:
|
||||
"""매수 체결 기록. amount는 집행 금액(원), scores는 앙상블 신호."""
|
||||
self.today_buy_total += int(amount)
|
||||
self.buy_scores[ticker] = dict(scores)
|
||||
|
||||
def pop_buy_scores(self, ticker: str) -> Optional[dict]:
|
||||
"""매도 체결 시 앙상블 학습을 위해 매수 당시 신호를 반환하고 제거."""
|
||||
return self.buy_scores.pop(ticker, None)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 손절 관리
|
||||
# ──────────────────────────────────────────────
|
||||
def record_sell_outcome(self, outcome_pct: float, now: datetime) -> bool:
|
||||
"""
|
||||
매도 결과를 반영해 연속 손절 카운터 업데이트.
|
||||
|
||||
Returns:
|
||||
True — 임계치 도달 → 매수 일시중단 활성화됨
|
||||
False — 임계치 미도달
|
||||
"""
|
||||
if outcome_pct < 0:
|
||||
self.consecutive_stop_losses += 1
|
||||
if self.consecutive_stop_losses >= self.stop_loss_pause_threshold:
|
||||
self.buy_paused_until = now + timedelta(
|
||||
minutes=self.stop_loss_pause_minutes
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.consecutive_stop_losses = 0
|
||||
return False
|
||||
|
||||
def is_buy_paused(self, now: datetime) -> bool:
|
||||
"""
|
||||
매수 일시중단 상태 조회. 만료되면 자동 해제 + 카운터 리셋.
|
||||
"""
|
||||
if self.buy_paused_until is None:
|
||||
return False
|
||||
if now >= self.buy_paused_until:
|
||||
self.buy_paused_until = None
|
||||
self.consecutive_stop_losses = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 예수금 계산 (KIS T+2 보완)
|
||||
# ──────────────────────────────────────────────
|
||||
def effective_today_buy(self, kis_today_buy: int) -> int:
|
||||
"""
|
||||
KIS API가 반환한 당일 매수금(`thdt_buy_amt`)과
|
||||
로컬 누적값 중 더 큰 값을 신뢰.
|
||||
(모의투자는 T+2 미차감으로 인해 과소 보고되는 경우 있음)
|
||||
"""
|
||||
return max(int(kis_today_buy or 0), self.today_buy_total)
|
||||
|
||||
def available_deposit(self, raw_deposit: int, max_daily_buy_ratio: float,
|
||||
kis_today_buy: int = 0) -> int:
|
||||
"""
|
||||
당일 사용 가능한 예수금 계산.
|
||||
|
||||
max_daily_buy = raw_deposit × ratio
|
||||
avail = min(raw_deposit, max_daily_buy) − effective_today_buy
|
||||
"""
|
||||
if raw_deposit <= 0:
|
||||
return 0
|
||||
max_daily_buy = int(raw_deposit * max_daily_buy_ratio)
|
||||
used = self.effective_today_buy(kis_today_buy)
|
||||
return max(0, min(raw_deposit, max_daily_buy) - used)
|
||||
571
signal_v1/modules/strategy/process.py
Normal file
571
signal_v1/modules/strategy/process.py
Normal file
@@ -0,0 +1,571 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import numpy as np
|
||||
from modules.services.llm_client import get_llm_client
|
||||
from modules.analysis.technical import TechnicalAnalyzer
|
||||
from modules.analysis.deep_learning import ModelRegistry
|
||||
from modules.analysis.market_regime import MarketRegimeDetector
|
||||
from modules.analysis.ai_council import get_council
|
||||
from modules.analysis.ensemble import get_ensemble
|
||||
from modules.config import Config
|
||||
|
||||
# AI Council 마지막 호출 시각 캐시 (종목별, 과다 호출 방지)
|
||||
_council_last_call: dict = {}
|
||||
|
||||
|
||||
def get_predictor(ticker=None):
|
||||
"""워커 프로세스 내에서 ModelRegistry로 종목별 PricePredictor 관리"""
|
||||
registry = ModelRegistry.get_instance()
|
||||
return registry.get_predictor(ticker or "default")
|
||||
|
||||
|
||||
def get_ollama():
|
||||
"""LLMClient 싱글톤 반환 (Gemini 우선, Ollama 폴백)"""
|
||||
return get_llm_client()
|
||||
|
||||
|
||||
def calculate_position_size(total_capital, current_price, volatility, score, ai_confidence,
|
||||
max_per_stock=3000000, ticker=None):
|
||||
"""
|
||||
[v3.1] Modified Kelly Criterion 기반 포지션 사이징
|
||||
|
||||
핵심 원칙:
|
||||
1. Kelly Fraction: f* = (p*b - q) / b (과거 실전 승률 + 손익비 기반)
|
||||
- 데이터 부족 시 보수적 기본값 8% 사용
|
||||
- Half-Kelly 적용으로 변동성 과대추정 보완
|
||||
2. 변동성 조절: ATR 기반 변동성에 따라 Kelly 비중 추가 조절
|
||||
3. 확신도 조절: 앙상블 score에 따른 최종 배수
|
||||
4. AI 신뢰도 가산: LSTM confidence 기반 (상한 0.80 반영)
|
||||
5. 상한: min(종목당 최대, 자산의 20%, 실제 자산)
|
||||
|
||||
Returns:
|
||||
int: 매수 수량 (0이면 매수 안 함)
|
||||
"""
|
||||
if current_price <= 0 or total_capital <= 0:
|
||||
return 0
|
||||
|
||||
# 1. Kelly Fraction 기반 기본 투자 비중
|
||||
ensemble = get_ensemble()
|
||||
kelly_f = ensemble.get_kelly_fraction(ticker=ticker, half_kelly=True)
|
||||
base_invest = total_capital * kelly_f
|
||||
|
||||
# 2. 변동성 조절 계수 (ATR% 기반, 변동성 높을수록 축소)
|
||||
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. 앙상블 확신도 조절 계수 (score 기반)
|
||||
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 신뢰도 가산 (LSTM confidence 상한 0.80 반영)
|
||||
ai_bonus = 1.0
|
||||
if ai_confidence >= 0.75:
|
||||
ai_bonus = 1.2
|
||||
elif ai_confidence >= 0.65:
|
||||
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.20) # 자산 20% 상한
|
||||
invest_amount = min(invest_amount, total_capital)
|
||||
|
||||
qty = int(invest_amount / current_price)
|
||||
kelly_pct = invest_amount / total_capital * 100 if total_capital > 0 else 0
|
||||
print(f" [Kelly] f={kelly_f:.2%} invest={invest_amount:,.0f}won ({kelly_pct:.1f}%) qty={qty}")
|
||||
return max(0, qty)
|
||||
|
||||
|
||||
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
||||
macro_status=None, holding_info=None, total_capital=None):
|
||||
"""
|
||||
[v3.1] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
|
||||
|
||||
[v3.1 개선사항]
|
||||
1. AdaptiveEnsemble 연동: 하드코딩 가중치 → 학습 기반 동적 가중치
|
||||
2. Kelly Criterion 기반 포지션 사이징 (calculate_position_size)
|
||||
3. 파일 mtime 동기화: 메인 프로세스의 record_trade 결과를 워커에 반영
|
||||
[v3.0 기능 유지]
|
||||
4. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
|
||||
5. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
|
||||
6. 강화된 LLM 프롬프트
|
||||
"""
|
||||
try:
|
||||
# [v3.1] 메인 프로세스가 갱신한 앙상블 가중치 파일 감지 → 재로드
|
||||
get_ensemble().reload_if_stale()
|
||||
# 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']
|
||||
|
||||
# 연속 매수일 수: 가장 최근부터 역순으로 연속된 양수 일수만 카운트
|
||||
for day in reversed(investor_trend):
|
||||
if day['foreigner'] > 0:
|
||||
consecutive_frgn_buy += 1
|
||||
else:
|
||||
break
|
||||
for day in reversed(investor_trend):
|
||||
if day['institutional'] > 0:
|
||||
consecutive_orgn_buy += 1
|
||||
else:
|
||||
break
|
||||
|
||||
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. 통합 점수 (AdaptiveEnsemble v3.1) =====
|
||||
# 하드코딩 가중치 → 학습 기반 동적 가중치 (과거 매매 결과 반영)
|
||||
adx_val = ma_info.get('adx', 20)
|
||||
|
||||
ensemble = get_ensemble()
|
||||
weights = ensemble.get_weights(
|
||||
ticker=ticker,
|
||||
adx=adx_val,
|
||||
macro_state=macro_state,
|
||||
ai_confidence=ai_confidence
|
||||
)
|
||||
print(f" [Ensemble] tech={weights.tech:.2f} news={weights.sentiment:.2f} "
|
||||
f"lstm={weights.lstm:.2f} (adx={adx_val:.0f} conf={ai_confidence:.2f})")
|
||||
|
||||
total_score = ensemble.compute_ensemble_score(
|
||||
tech_score=tech_score,
|
||||
sentiment_score=sentiment_score,
|
||||
lstm_score=lstm_score,
|
||||
investor_score=investor_score,
|
||||
weights=weights
|
||||
)
|
||||
|
||||
# ===== 7.5. 시장 레짐 감지 (코스피 수준 기반) =====
|
||||
kospi_price = 0.0
|
||||
kospi_change_val = 0.0
|
||||
regime_analysis = None
|
||||
if macro_status:
|
||||
kospi_info = macro_status.get('indicators', {}).get('KOSPI', {})
|
||||
kospi_price = float(kospi_info.get('price', 0) or 0)
|
||||
kospi_change_val = float(kospi_info.get('change', 0) or 0)
|
||||
|
||||
if Config.MARKET_REGIME_ENABLED and kospi_price > 0:
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi_price, kospi_change_val)
|
||||
print(
|
||||
f" 📈 [Regime] {MarketRegimeDetector.get_regime_label(kospi_price)} "
|
||||
f"risk={regime_analysis.risk_level} "
|
||||
f"buy_adj={regime_analysis.buy_threshold_adj:+.2f} "
|
||||
f"pos=x{regime_analysis.position_size_adj:.2f}"
|
||||
)
|
||||
|
||||
# ===== 8. 시장 상황별 동적 임계값 =====
|
||||
buy_threshold = 0.60
|
||||
sell_threshold = 0.30
|
||||
danger_force_sell = False # DANGER 긴급 매도 플래그
|
||||
|
||||
if macro_status:
|
||||
if macro_state == 'DANGER':
|
||||
buy_threshold = 999.0
|
||||
sell_threshold = 0.35 # 이전 0.45에서 하향 (더 적극적 손절)
|
||||
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold lowered to 0.35")
|
||||
# 보유 중이고 손실이면 즉시 매도 플래그
|
||||
if holding_info and holding_info.get('qty', 0) > 0:
|
||||
hy = holding_info.get('yield', 0.0)
|
||||
if hy < -3.0:
|
||||
danger_force_sell = True
|
||||
print(f" 🚨 [DANGER + Loss {hy:.1f}%] Emergency Sell Triggered")
|
||||
elif macro_state == 'CAUTION':
|
||||
buy_threshold = 0.72
|
||||
sell_threshold = 0.38
|
||||
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
|
||||
|
||||
# 레짐 기반 임계값 추가 조정 (거시경제 판단 이후 적용)
|
||||
if regime_analysis and macro_state != 'DANGER':
|
||||
buy_threshold = round(
|
||||
max(0.55, buy_threshold + regime_analysis.buy_threshold_adj), 3
|
||||
)
|
||||
|
||||
# ===== 9. 매매 결정 =====
|
||||
decision = "HOLD"
|
||||
decision_reason = ""
|
||||
|
||||
# DANGER 긴급 매도 (손실 보유종목)
|
||||
if danger_force_sell:
|
||||
decision = "SELL"
|
||||
decision_reason = f"Emergency DANGER Market + Loss ({holding_info.get('yield', 0.0):.1f}%)"
|
||||
|
||||
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.78 and ai_confidence >= 0.75 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. 포지션 사이징 =====
|
||||
# total_capital: 호출 측에서 실제 잔고 전달 (없으면 보수적 기본값 5M)
|
||||
_capital = total_capital if (total_capital and total_capital > 0) else 5_000_000
|
||||
suggested_qty = 0
|
||||
if decision == "BUY":
|
||||
suggested_qty = calculate_position_size(
|
||||
total_capital=_capital,
|
||||
current_price=current_price,
|
||||
volatility=volatility,
|
||||
score=total_score,
|
||||
ai_confidence=ai_confidence,
|
||||
ticker=ticker
|
||||
)
|
||||
if suggested_qty == 0:
|
||||
decision = "HOLD"
|
||||
decision_reason = "Position size too small"
|
||||
|
||||
# 레짐 기반 포지션 크기 조정 (이미 계산된 수량에 배수 적용)
|
||||
if regime_analysis and suggested_qty > 0:
|
||||
adjusted_qty = int(suggested_qty * regime_analysis.position_size_adj)
|
||||
if adjusted_qty != suggested_qty:
|
||||
print(f" 📐 [Regime] 포지션 조정: {suggested_qty} → {adjusted_qty}주 "
|
||||
f"(x{regime_analysis.position_size_adj:.2f})")
|
||||
suggested_qty = max(0, adjusted_qty)
|
||||
if suggested_qty == 0:
|
||||
decision = "HOLD"
|
||||
decision_reason = "Regime position size adjustment → 0"
|
||||
|
||||
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 ''}")
|
||||
|
||||
# ===== 11. AI 전문가 회의 (선택적, Config.AI_COUNCIL_ENABLED) =====
|
||||
council_decision = None
|
||||
if Config.AI_COUNCIL_ENABLED:
|
||||
now = time.time()
|
||||
last_call = _council_last_call.get(ticker, 0)
|
||||
if now - last_call >= Config.AI_COUNCIL_MIN_INTERVAL:
|
||||
_council_last_call[ticker] = now
|
||||
council_data = {
|
||||
"current_price": current_price,
|
||||
"kospi_price": kospi_price,
|
||||
"macro_state": macro_state,
|
||||
"tech_score": tech_score,
|
||||
"rsi": rsi,
|
||||
"adx": adx_val,
|
||||
"volatility": volatility,
|
||||
"bb_zone": bb_zone,
|
||||
"mtf_alignment": ma_info.get('mtf_alignment', 'N/A'),
|
||||
"lstm_predicted": (
|
||||
pred_result.get('predicted', current_price)
|
||||
if pred_result else current_price
|
||||
),
|
||||
"lstm_change_rate": (
|
||||
pred_result.get('change_rate', 0) if pred_result else 0
|
||||
),
|
||||
"ai_confidence": ai_confidence,
|
||||
"lstm_score": lstm_score,
|
||||
"sentiment_score": sentiment_score,
|
||||
"investor_score": investor_score,
|
||||
"frgn_net_buy": frgn_net_buy,
|
||||
"consecutive_frgn_buy": consecutive_frgn_buy,
|
||||
"is_holding": (
|
||||
holding_info.get('qty', 0) > 0 if holding_info else False
|
||||
),
|
||||
"holding_yield": (
|
||||
holding_info.get('yield', 0.0) if holding_info else 0.0
|
||||
),
|
||||
"total_score": total_score,
|
||||
}
|
||||
try:
|
||||
council = get_council(get_ollama())
|
||||
council_decision = council.convene(
|
||||
ticker, council_data,
|
||||
regime_analysis=regime_analysis,
|
||||
fast_mode=Config.AI_COUNCIL_FAST_MODE,
|
||||
)
|
||||
# 모델 교체 권고 경고 출력
|
||||
if council_decision.model_replacement_recommended:
|
||||
print(
|
||||
f" ⚠️ [Council] 모델 교체 권고: "
|
||||
f"{council_decision.recommended_model}"
|
||||
)
|
||||
# 회의 결정이 기존 결정과 다르고 신뢰도 높으면 우선 적용
|
||||
if council_decision.confidence >= 0.75:
|
||||
council_final = council_decision.final_decision.upper()
|
||||
if council_final != decision:
|
||||
print(
|
||||
f" 🔄 [Council Override] {decision} → {council_final} "
|
||||
f"(conf={council_decision.confidence:.2f})"
|
||||
)
|
||||
decision = council_final
|
||||
decision_reason = (
|
||||
f"AI Council ({council_decision.confidence:.0%}): "
|
||||
f"{council_decision.majority_reasoning[:80]}"
|
||||
)
|
||||
# BUY로 전환된 경우 수량 재계산
|
||||
if decision == "BUY" and suggested_qty == 0:
|
||||
suggested_qty = calculate_position_size(
|
||||
total_capital=_capital,
|
||||
current_price=current_price,
|
||||
volatility=volatility,
|
||||
score=council_decision.confidence,
|
||||
ai_confidence=ai_confidence,
|
||||
ticker=ticker,
|
||||
)
|
||||
except Exception as _ce:
|
||||
print(f" [Council] 회의 오류: {_ce}")
|
||||
|
||||
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,
|
||||
"regime": {
|
||||
"kospi_level": kospi_price,
|
||||
"regime": regime_analysis.regime.value if regime_analysis else "unknown",
|
||||
"description": regime_analysis.description if regime_analysis else "",
|
||||
"risk_level": regime_analysis.risk_level if regime_analysis else "LOW",
|
||||
"model_recommendation": (
|
||||
regime_analysis.model_recommendation if regime_analysis else ""
|
||||
),
|
||||
} if regime_analysis else None,
|
||||
"council": {
|
||||
"final": council_decision.final_decision,
|
||||
"confidence": council_decision.confidence,
|
||||
"model_health": council_decision.model_health_score,
|
||||
"replace_recommended": council_decision.model_replacement_recommended,
|
||||
"recommended_model": council_decision.recommended_model,
|
||||
"summary": council_decision.council_summary,
|
||||
} if council_decision else None,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
150
signal_v1/modules/strategy/risk_gate.py
Normal file
150
signal_v1/modules/strategy/risk_gate.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
포트폴리오 리스크 게이트 (v3.2)
|
||||
|
||||
매수 체결 직전 호출되어 포트폴리오 레벨 제약을 검증:
|
||||
1. 총 보유 종목 수 상한
|
||||
2. 테마당 동시 보유 종목 수 상한
|
||||
3. 테마당 노출 금액 비율 상한 (총자산 대비)
|
||||
|
||||
기존 매수 필터(예수금, 종목당 상한, 사이클당 매수 수)는 유지하고
|
||||
이 게이트가 "같은 테마에 집중되는 포지션"을 차단한다.
|
||||
|
||||
순수 함수로 구현 — 의존성 없음 → 단위 테스트 가능.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskDecision:
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
max_allowed_amount: int = 0 # 일부만 허용되는 경우 (테마 노출 상한)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskConfig:
|
||||
max_total_holdings: int = 7
|
||||
max_tickers_per_theme: int = 2
|
||||
max_theme_exposure_ratio: float = 0.40
|
||||
|
||||
|
||||
class PortfolioRiskGate:
|
||||
"""
|
||||
사용 예:
|
||||
gate = PortfolioRiskGate(theme_map, RiskConfig())
|
||||
decision = gate.evaluate_buy(
|
||||
ticker="005930",
|
||||
candidate_amount=3_000_000,
|
||||
current_holdings=[{"ticker":"000660","eval_amount":2_500_000}, ...],
|
||||
total_capital=50_000_000,
|
||||
)
|
||||
if not decision.allowed: skip
|
||||
elif decision.max_allowed_amount < candidate_amount: partial buy
|
||||
"""
|
||||
|
||||
def __init__(self, theme_lookup, config: Optional[RiskConfig] = None):
|
||||
"""
|
||||
Args:
|
||||
theme_lookup: callable(ticker:str) -> list[str] (종목→테마 매핑 함수)
|
||||
혹은 dict 형태도 허용.
|
||||
config: RiskConfig
|
||||
"""
|
||||
if callable(theme_lookup):
|
||||
self._theme_of = theme_lookup
|
||||
elif isinstance(theme_lookup, dict):
|
||||
self._theme_of = lambda t: theme_lookup.get(t, [])
|
||||
else:
|
||||
raise TypeError("theme_lookup must be callable or dict")
|
||||
self.config = config or RiskConfig()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 내부: 테마별 현재 노출 집계
|
||||
# ──────────────────────────────────────────────
|
||||
def _aggregate_by_theme(self, holdings: Iterable[dict]) -> Dict[str, dict]:
|
||||
"""
|
||||
Returns:
|
||||
{theme: {"tickers": set, "amount": int}}
|
||||
"""
|
||||
agg: Dict[str, dict] = {}
|
||||
for h in holdings:
|
||||
tkr = h.get("ticker")
|
||||
amt = int(h.get("eval_amount", 0) or 0)
|
||||
if not tkr:
|
||||
continue
|
||||
themes = self._theme_of(tkr) or []
|
||||
for th in themes:
|
||||
bucket = agg.setdefault(th, {"tickers": set(), "amount": 0})
|
||||
bucket["tickers"].add(tkr)
|
||||
bucket["amount"] += amt
|
||||
return agg
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 공개 API
|
||||
# ──────────────────────────────────────────────
|
||||
def evaluate_buy(self, ticker: str, candidate_amount: int,
|
||||
current_holdings: List[dict],
|
||||
total_capital: int) -> RiskDecision:
|
||||
"""
|
||||
매수 허가 여부 판단.
|
||||
|
||||
Returns:
|
||||
RiskDecision
|
||||
- allowed=False: 이유와 함께 차단
|
||||
- allowed=True : max_allowed_amount만큼 허용 (candidate_amount 이하)
|
||||
"""
|
||||
if candidate_amount <= 0 or total_capital <= 0:
|
||||
return RiskDecision(False, "invalid_amount")
|
||||
|
||||
cfg = self.config
|
||||
|
||||
# 이미 보유 중이면 추가 매수는 이 게이트 대상 아님 (scale-in은 상위에서 처리)
|
||||
held_tickers = {h.get("ticker") for h in current_holdings}
|
||||
is_new_position = ticker not in held_tickers
|
||||
|
||||
# 1. 총 보유 종목 수 상한
|
||||
if is_new_position and len(held_tickers) >= cfg.max_total_holdings:
|
||||
return RiskDecision(
|
||||
False,
|
||||
f"max_total_holdings: {len(held_tickers)}/{cfg.max_total_holdings}"
|
||||
)
|
||||
|
||||
themes = self._theme_of(ticker) or []
|
||||
if not themes:
|
||||
# 테마 정보 없음 → 테마 제약은 건너뛰고 통과
|
||||
return RiskDecision(True, "no_theme_info", candidate_amount)
|
||||
|
||||
by_theme = self._aggregate_by_theme(current_holdings)
|
||||
|
||||
allowed_amount = candidate_amount
|
||||
blocking_reasons = []
|
||||
|
||||
for th in themes:
|
||||
bucket = by_theme.get(th, {"tickers": set(), "amount": 0})
|
||||
|
||||
# 2. 테마당 종목 수 상한 (신규 포지션일 때만)
|
||||
if is_new_position and len(bucket["tickers"]) >= cfg.max_tickers_per_theme:
|
||||
blocking_reasons.append(
|
||||
f"theme[{th}] tickers {len(bucket['tickers'])}/{cfg.max_tickers_per_theme}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 3. 테마당 노출 금액 비율 상한
|
||||
max_theme_amount = int(total_capital * cfg.max_theme_exposure_ratio)
|
||||
remaining = max_theme_amount - bucket["amount"]
|
||||
if remaining <= 0:
|
||||
blocking_reasons.append(
|
||||
f"theme[{th}] exposure {bucket['amount']:,}/{max_theme_amount:,}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 테마 잔여액이 candidate보다 작으면 부분 허용
|
||||
allowed_amount = min(allowed_amount, remaining)
|
||||
|
||||
if blocking_reasons:
|
||||
return RiskDecision(False, "; ".join(blocking_reasons))
|
||||
|
||||
if allowed_amount <= 0:
|
||||
return RiskDecision(False, "theme_exposure_full")
|
||||
|
||||
return RiskDecision(True, "ok", allowed_amount)
|
||||
208
signal_v1/modules/utils/ipc.py
Normal file
208
signal_v1/modules/utils/ipc.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
프로세스 간 통신 (IPC) - Shared Memory 기반
|
||||
텔레그램 봇과 메인 봇 간 데이터 공유 + 양방향 명령 채널
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import struct
|
||||
from multiprocessing.shared_memory import SharedMemory
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
class SharedIPC:
|
||||
"""Shared Memory + Command Queue 기반 IPC"""
|
||||
|
||||
def __init__(self, lock=None, command_queue=None):
|
||||
self.lock = lock
|
||||
self.command_queue = command_queue
|
||||
self._shm = None
|
||||
self._is_creator = False
|
||||
|
||||
def _ensure_shm(self):
|
||||
"""SharedMemory 블록에 연결 (없으면 생성)"""
|
||||
if self._shm is not None:
|
||||
return self._shm
|
||||
try:
|
||||
self._shm = SharedMemory(name=Config.SHM_NAME, create=False)
|
||||
except FileNotFoundError:
|
||||
self._shm = SharedMemory(name=Config.SHM_NAME, create=True, size=Config.SHM_SIZE)
|
||||
self._is_creator = True
|
||||
# 초기화: 길이 필드를 0으로 설정
|
||||
struct.pack_into('I', self._shm.buf, 0, 0)
|
||||
return self._shm
|
||||
|
||||
def write_status(self, data):
|
||||
"""메인 봇이 상태를 shared memory에 기록"""
|
||||
try:
|
||||
shm = self._ensure_shm()
|
||||
payload = json.dumps({
|
||||
'timestamp': time.time(),
|
||||
'data': data
|
||||
}, ensure_ascii=False).encode('utf-8')
|
||||
|
||||
if len(payload) + 4 > Config.SHM_SIZE:
|
||||
print(f"[IPC] Data too large: {len(payload)} bytes")
|
||||
return
|
||||
|
||||
if self.lock:
|
||||
self.lock.acquire()
|
||||
try:
|
||||
# [4바이트 길이][JSON 페이로드]
|
||||
struct.pack_into('I', shm.buf, 0, len(payload))
|
||||
shm.buf[4:4 + len(payload)] = payload
|
||||
finally:
|
||||
if self.lock:
|
||||
self.lock.release()
|
||||
except Exception as e:
|
||||
print(f"[IPC] Write failed: {e}")
|
||||
|
||||
def read_status(self):
|
||||
"""텔레그램 봇이 상태를 shared memory에서 읽기"""
|
||||
raw = None
|
||||
try:
|
||||
shm = self._ensure_shm()
|
||||
|
||||
if self.lock:
|
||||
self.lock.acquire()
|
||||
try:
|
||||
length = struct.unpack_from('I', shm.buf, 0)[0]
|
||||
if length > 0 and length <= Config.SHM_SIZE - 4:
|
||||
raw = bytes(shm.buf[4:4 + length])
|
||||
finally:
|
||||
if self.lock:
|
||||
self.lock.release()
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
ipc_data = json.loads(raw.decode('utf-8'))
|
||||
age = time.time() - ipc_data.get('timestamp', 0)
|
||||
|
||||
if age > Config.IPC_STALENESS:
|
||||
print(f"[IPC] Data too old: {age:.1f}s")
|
||||
return None
|
||||
|
||||
return ipc_data.get('data')
|
||||
except Exception as e:
|
||||
print(f"[IPC] Read failed: {e}")
|
||||
return None
|
||||
|
||||
# --- 명령 채널 (텔레그램 → 메인 봇) ---
|
||||
|
||||
def send_command(self, command, **kwargs):
|
||||
"""텔레그램 → 메인 봇 명령 전송"""
|
||||
if self.command_queue:
|
||||
try:
|
||||
self.command_queue.put_nowait({
|
||||
'command': command,
|
||||
'timestamp': time.time(),
|
||||
**kwargs
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[IPC] Command send failed: {e}")
|
||||
return False
|
||||
|
||||
def poll_commands(self):
|
||||
"""메인 봇이 명령 큐를 폴링"""
|
||||
commands = []
|
||||
if self.command_queue:
|
||||
try:
|
||||
while not self.command_queue.empty():
|
||||
cmd = self.command_queue.get_nowait()
|
||||
commands.append(cmd)
|
||||
except Exception:
|
||||
pass
|
||||
return commands
|
||||
|
||||
# --- FakeBot 인스턴스 (호환성 유지) ---
|
||||
|
||||
def get_bot_instance_data(self):
|
||||
"""봇 인스턴스 데이터 가져오기 (텔레그램 봇용)"""
|
||||
status = self.read_status()
|
||||
if not status:
|
||||
return None
|
||||
|
||||
class FakeBotInstance:
|
||||
def __init__(self, data):
|
||||
self.kis = FakeKIS(data.get('balance', {}), data.get('macro_indices', {}))
|
||||
self.ollama_monitor = FakeOllama(data.get('gpu', {}))
|
||||
self.theme_manager = FakeThemeManager(data.get('themes', {}))
|
||||
self.discovered_stocks = set(data.get('discovered_stocks', []))
|
||||
self.is_macro_warning_sent = data.get('is_macro_warning', False)
|
||||
self.watchlist_manager = FakeWatchlistManager(data.get('watchlist', {}))
|
||||
self.load_watchlist = lambda: data.get('watchlist', {})
|
||||
|
||||
class FakeKIS:
|
||||
def __init__(self, balance_data, macro_indices):
|
||||
self._balance = balance_data if balance_data else {
|
||||
'total_eval': 0, 'deposit': 0, 'holdings': []
|
||||
}
|
||||
self._macro_indices = macro_indices if macro_indices else {}
|
||||
|
||||
def get_balance(self):
|
||||
return self._balance
|
||||
|
||||
def get_current_index(self, ticker):
|
||||
if ticker in self._macro_indices:
|
||||
return self._macro_indices[ticker]
|
||||
return {'price': 2500.0, 'change': 0.0}
|
||||
|
||||
def get_daily_index_price(self, ticker, period="D"):
|
||||
base_price = 2500.0
|
||||
if ticker in self._macro_indices:
|
||||
base_price = self._macro_indices[ticker].get('price', 2500.0)
|
||||
import random
|
||||
return [base_price * (1 + random.uniform(-0.02, 0.02)) for _ in range(20)]
|
||||
|
||||
def get_current_price(self, ticker):
|
||||
return None
|
||||
|
||||
def get_daily_price(self, ticker, period="D"):
|
||||
return []
|
||||
|
||||
def get_volume_rank(self, market="0"):
|
||||
return []
|
||||
|
||||
def buy_stock(self, ticker, qty):
|
||||
return {"success": False, "msg": "IPC mode"}
|
||||
|
||||
def sell_stock(self, ticker, qty):
|
||||
return {"success": False, "msg": "IPC mode"}
|
||||
|
||||
class FakeOllama:
|
||||
def __init__(self, gpu_data):
|
||||
self._gpu = gpu_data if gpu_data else {
|
||||
'name': 'N/A', 'temp': 0, 'vram_used': 0, 'vram_total': 0, 'load': 0
|
||||
}
|
||||
|
||||
def get_gpu_status(self):
|
||||
return self._gpu
|
||||
|
||||
class FakeThemeManager:
|
||||
def __init__(self, themes_data):
|
||||
self._themes = themes_data if themes_data else {}
|
||||
|
||||
def get_themes(self, ticker):
|
||||
return self._themes.get(ticker, [])
|
||||
|
||||
class FakeWatchlistManager:
|
||||
def __init__(self, watchlist_data):
|
||||
self._watchlist = watchlist_data if watchlist_data else {}
|
||||
|
||||
def update_watchlist_daily(self):
|
||||
return "Watchlist update not available in IPC mode"
|
||||
|
||||
return FakeBotInstance(status)
|
||||
|
||||
def cleanup(self):
|
||||
"""리소스 정리"""
|
||||
if self._shm:
|
||||
try:
|
||||
self._shm.close()
|
||||
if self._is_creator:
|
||||
self._shm.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
self._shm = None
|
||||
213
signal_v1/modules/utils/market_calendar.py
Normal file
213
signal_v1/modules/utils/market_calendar.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
KRX (한국거래소) 시장 캘린더
|
||||
장 운영: 평일 09:00~15:30 KST (공휴일 제외)
|
||||
|
||||
우선순위:
|
||||
1. exchange_calendars 라이브러리 (pip install exchange-calendars) → 음력 자동 계산
|
||||
2. 하드코딩 폴백 (2024~2026 공휴일 내장)
|
||||
"""
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
MARKET_OPEN = datetime.time(9, 0)
|
||||
MARKET_CLOSE = datetime.time(15, 30)
|
||||
|
||||
# ── KRX 공휴일 하드코딩 (exchange_calendars 미설치 시 폴백) ──────────────────
|
||||
# 출처: KRX 공식 휴장일 공고 (2024~2026)
|
||||
STATIC_HOLIDAYS: frozenset[datetime.date] = frozenset({
|
||||
# 2024
|
||||
datetime.date(2024, 1, 1), # 신정
|
||||
datetime.date(2024, 2, 9), # 설날 연휴
|
||||
datetime.date(2024, 2, 12), # 대체공휴일
|
||||
datetime.date(2024, 3, 1), # 삼일절
|
||||
datetime.date(2024, 4, 10), # 국회의원선거
|
||||
datetime.date(2024, 5, 5), # 어린이날
|
||||
datetime.date(2024, 5, 6), # 대체공휴일
|
||||
datetime.date(2024, 5, 15), # 부처님오신날
|
||||
datetime.date(2024, 6, 6), # 현충일
|
||||
datetime.date(2024, 8, 15), # 광복절
|
||||
datetime.date(2024, 9, 16), # 추석 연휴
|
||||
datetime.date(2024, 9, 17), # 추석
|
||||
datetime.date(2024, 9, 18), # 추석 연휴
|
||||
datetime.date(2024, 10, 3), # 개천절
|
||||
datetime.date(2024, 10, 9), # 한글날
|
||||
datetime.date(2024, 12, 25), # 성탄절
|
||||
datetime.date(2024, 12, 31), # 연말 휴장
|
||||
# 2025
|
||||
datetime.date(2025, 1, 1), # 신정
|
||||
datetime.date(2025, 1, 28), # 설날 연휴
|
||||
datetime.date(2025, 1, 29), # 설날
|
||||
datetime.date(2025, 1, 30), # 설날 연휴
|
||||
datetime.date(2025, 3, 1), # 삼일절
|
||||
datetime.date(2025, 3, 3), # 대체공휴일
|
||||
datetime.date(2025, 5, 5), # 어린이날
|
||||
datetime.date(2025, 5, 6), # 대체공휴일
|
||||
datetime.date(2025, 6, 6), # 현충일
|
||||
datetime.date(2025, 8, 15), # 광복절
|
||||
datetime.date(2025, 10, 2), # 대체공휴일
|
||||
datetime.date(2025, 10, 3), # 개천절
|
||||
datetime.date(2025, 10, 6), # 추석 연휴
|
||||
datetime.date(2025, 10, 7), # 추석
|
||||
datetime.date(2025, 10, 8), # 추석 연휴
|
||||
datetime.date(2025, 10, 9), # 한글날
|
||||
datetime.date(2025, 12, 25), # 성탄절
|
||||
datetime.date(2025, 12, 31), # 연말 휴장
|
||||
# 2026
|
||||
datetime.date(2026, 1, 1), # 신정
|
||||
datetime.date(2026, 2, 16), # 설날 연휴
|
||||
datetime.date(2026, 2, 17), # 설날
|
||||
datetime.date(2026, 2, 18), # 설날 연휴
|
||||
datetime.date(2026, 3, 1), # 삼일절
|
||||
datetime.date(2026, 3, 2), # 대체공휴일
|
||||
datetime.date(2026, 5, 5), # 어린이날
|
||||
datetime.date(2026, 5, 24), # 부처님오신날
|
||||
datetime.date(2026, 6, 6), # 현충일
|
||||
datetime.date(2026, 8, 14), # 대체공휴일
|
||||
datetime.date(2026, 8, 15), # 광복절
|
||||
datetime.date(2026, 9, 24), # 추석 연휴
|
||||
datetime.date(2026, 9, 25), # 추석
|
||||
datetime.date(2026, 10, 3), # 개천절
|
||||
datetime.date(2026, 10, 9), # 한글날
|
||||
datetime.date(2026, 12, 25), # 성탄절
|
||||
datetime.date(2026, 12, 31), # 연말 휴장
|
||||
})
|
||||
|
||||
|
||||
class KRXCalendar:
|
||||
"""
|
||||
KRX 시장 캘린더
|
||||
|
||||
>>> cal = KRXCalendar()
|
||||
>>> cal.is_trading_day(datetime.date(2026, 1, 1)) # 신정
|
||||
False
|
||||
>>> cal.is_trading_day(datetime.date(2026, 1, 2)) # 평일
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._ec_cal = None
|
||||
try:
|
||||
import exchange_calendars as ec
|
||||
self._ec_cal = ec.get_calendar("XKRX")
|
||||
print("[KRXCalendar] exchange_calendars 로드 성공 (정확한 음력 공휴일 사용)")
|
||||
except ImportError:
|
||||
print("[KRXCalendar] exchange_calendars 미설치 → 하드코딩 폴백 (pip install exchange-calendars 권장)")
|
||||
except Exception as e:
|
||||
print(f"[KRXCalendar] exchange_calendars 로드 실패: {e} → 폴백 사용")
|
||||
|
||||
# ── 날짜 판별 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def is_trading_day(self, date: datetime.date | None = None) -> bool:
|
||||
"""주어진 날짜가 KRX 거래일인지 확인 (기본: 오늘 KST)"""
|
||||
if date is None:
|
||||
date = datetime.datetime.now(KST).date()
|
||||
if date.weekday() >= 5: # 토(5), 일(6)
|
||||
return False
|
||||
if self._ec_cal:
|
||||
try:
|
||||
return self._ec_cal.is_session(date.isoformat())
|
||||
except Exception:
|
||||
pass
|
||||
return date not in STATIC_HOLIDAYS
|
||||
|
||||
def now_kst(self) -> datetime.datetime:
|
||||
"""현재 KST 시각"""
|
||||
return datetime.datetime.now(KST)
|
||||
|
||||
def is_market_open(self) -> bool:
|
||||
"""현재 KST 기준 장 중 여부 (09:00 ≤ time < 15:30)"""
|
||||
now = self.now_kst()
|
||||
if not self.is_trading_day(now.date()):
|
||||
return False
|
||||
return MARKET_OPEN <= now.time() < MARKET_CLOSE
|
||||
|
||||
def is_pre_market(self) -> bool:
|
||||
"""장 시작 전 (당일 거래일이고 09:00 이전)"""
|
||||
now = self.now_kst()
|
||||
return self.is_trading_day(now.date()) and now.time() < MARKET_OPEN
|
||||
|
||||
def is_post_market(self) -> bool:
|
||||
"""장 마감 후 (당일 거래일이고 15:30 이후)"""
|
||||
now = self.now_kst()
|
||||
return self.is_trading_day(now.date()) and now.time() >= MARKET_CLOSE
|
||||
|
||||
# ── 다음 장 시각 계산 ──────────────────────────────────────────────────────
|
||||
|
||||
def next_trading_open(self) -> datetime.datetime:
|
||||
"""
|
||||
다음 장 시작 시각 (KST)
|
||||
- 오늘이 거래일이고 아직 09:00 이전 → 오늘 09:00 반환
|
||||
- 그 외 → 다음 거래일 09:00 반환
|
||||
"""
|
||||
now = self.now_kst()
|
||||
date = now.date()
|
||||
if self.is_trading_day(date) and now.time() < MARKET_OPEN:
|
||||
return datetime.datetime.combine(date, MARKET_OPEN, tzinfo=KST)
|
||||
# 다음 거래일 탐색 (최대 14일)
|
||||
next_date = date + datetime.timedelta(days=1)
|
||||
for _ in range(14):
|
||||
if self.is_trading_day(next_date):
|
||||
return datetime.datetime.combine(next_date, MARKET_OPEN, tzinfo=KST)
|
||||
next_date += datetime.timedelta(days=1)
|
||||
raise RuntimeError("14일 이내에 거래일을 찾지 못했습니다.")
|
||||
|
||||
def today_close(self) -> datetime.datetime | None:
|
||||
"""오늘 장 종료 시각. 오늘이 거래일이 아니면 None."""
|
||||
now = self.now_kst()
|
||||
if not self.is_trading_day(now.date()):
|
||||
return None
|
||||
return datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST)
|
||||
|
||||
# ── 잔여 시간 계산 ──────────────────────────────────────────────────────────
|
||||
|
||||
def seconds_to_open(self) -> float:
|
||||
"""장 시작까지 남은 초 (이미 장 중이거나 장 마감 후면 0)"""
|
||||
if self.is_market_open():
|
||||
return 0.0
|
||||
try:
|
||||
return max(0.0, (self.next_trading_open() - self.now_kst()).total_seconds())
|
||||
except RuntimeError:
|
||||
return 0.0
|
||||
|
||||
def seconds_to_close(self) -> float:
|
||||
"""장 종료까지 남은 초 (장 외 시간이면 0)"""
|
||||
now = self.now_kst()
|
||||
if not self.is_trading_day(now.date()):
|
||||
return 0.0
|
||||
close_dt = datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST)
|
||||
return max(0.0, (close_dt - now).total_seconds())
|
||||
|
||||
def minutes_to_close(self) -> float:
|
||||
return self.seconds_to_close() / 60
|
||||
|
||||
# ── 상태 요약 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def status_summary(self) -> str:
|
||||
"""현재 시장 상태 요약 문자열 (로그/알림용)"""
|
||||
now = self.now_kst()
|
||||
today = now.date()
|
||||
if not self.is_trading_day(today):
|
||||
try:
|
||||
nxt = self.next_trading_open()
|
||||
return f"휴장 | 다음 거래일: {nxt.strftime('%m/%d(%a) %H:%M')}"
|
||||
except Exception:
|
||||
return "휴장"
|
||||
if self.is_market_open():
|
||||
mins = int(self.minutes_to_close())
|
||||
return f"장 중 | 마감까지 {mins}분"
|
||||
if now.time() < MARKET_OPEN:
|
||||
secs = self.seconds_to_open()
|
||||
return f"장 시작 전 | 개장까지 {int(secs / 60)}분"
|
||||
return "장 마감"
|
||||
|
||||
|
||||
# 싱글톤 (프로세스 내 공유)
|
||||
_calendar: KRXCalendar | None = None
|
||||
|
||||
|
||||
def get_calendar() -> KRXCalendar:
|
||||
global _calendar
|
||||
if _calendar is None:
|
||||
_calendar = KRXCalendar()
|
||||
return _calendar
|
||||
110
signal_v1/modules/utils/monitor.py
Normal file
110
signal_v1/modules/utils/monitor.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import psutil
|
||||
from datetime import datetime
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
class SystemMonitor:
|
||||
def __init__(self, messenger, ollama_manager):
|
||||
self.messenger = messenger
|
||||
self.ollama_monitor = ollama_manager
|
||||
self.last_health_check = datetime.now()
|
||||
|
||||
# CPU 서킷 브레이커 상태
|
||||
self._cpu_overload_count = 0 # 연속 과부하 횟수
|
||||
self._circuit_open = False # 서킷 브레이커 발동 여부
|
||||
self._circuit_open_since = None
|
||||
|
||||
def is_cpu_critical(self):
|
||||
"""서킷 브레이커가 발동 상태인지 반환 (True이면 분석 사이클 스킵)"""
|
||||
return self._circuit_open
|
||||
|
||||
def reset_circuit(self):
|
||||
"""서킷 브레이커 수동 리셋"""
|
||||
if self._circuit_open:
|
||||
print("[Monitor] CPU Circuit Breaker RESET")
|
||||
self._circuit_open = False
|
||||
self._cpu_overload_count = 0
|
||||
self._circuit_open_since = None
|
||||
|
||||
def check_health(self):
|
||||
"""시스템 상태 점검 및 알림 (CPU, RAM, GPU) - 3분마다 실행"""
|
||||
now = datetime.now()
|
||||
if (now - self.last_health_check).total_seconds() < 180:
|
||||
return
|
||||
|
||||
self.last_health_check = now
|
||||
alerts = []
|
||||
|
||||
# 1. CPU Check
|
||||
cpu_usage = psutil.cpu_percent(interval=1) # 1초 측정 (더 정확)
|
||||
|
||||
if cpu_usage > Config.CPU_CIRCUIT_BREAKER_THRESHOLD:
|
||||
self._cpu_overload_count += 1
|
||||
|
||||
# 상위 프로세스 조회
|
||||
top_processes = []
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
|
||||
try:
|
||||
if proc.info['name'] in ('System Idle Process', 'Idle'):
|
||||
continue
|
||||
top_processes.append(proc.info)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
|
||||
top_processes.sort(key=lambda x: x['cpu_percent'], reverse=True)
|
||||
top_3_str = ""
|
||||
for p in top_processes[:3]:
|
||||
top_3_str += f"\n- {p['name']} ({p['cpu_percent']}%)"
|
||||
|
||||
# 서킷 브레이커 발동 조건
|
||||
if self._cpu_overload_count >= Config.CPU_CIRCUIT_BREAKER_CONSECUTIVE:
|
||||
if not self._circuit_open:
|
||||
self._circuit_open = True
|
||||
self._circuit_open_since = now
|
||||
alerts.append(
|
||||
f"🔴 [CPU Circuit Breaker OPEN] {cpu_usage}% × {self._cpu_overload_count}회 연속\n"
|
||||
f"⛔ 분석 사이클 일시 중단 (5분 후 자동 복구)\nTop Processes:{top_3_str}"
|
||||
)
|
||||
print(f"[Monitor] CPU Circuit Breaker OPEN! CPU={cpu_usage}%")
|
||||
else:
|
||||
alerts.append(
|
||||
f"⚠️ [CPU Overload] Usage: {cpu_usage}% ({self._cpu_overload_count}회)\nTop Processes:{top_3_str}"
|
||||
)
|
||||
else:
|
||||
# CPU 정상 → 카운터 리셋
|
||||
if self._cpu_overload_count > 0:
|
||||
print(f"[Monitor] CPU 정상화 ({cpu_usage}%). 카운터 리셋.")
|
||||
self._cpu_overload_count = 0
|
||||
|
||||
# 서킷 브레이커가 열린 후 5분 경과 시 자동 복구
|
||||
if self._circuit_open and self._circuit_open_since:
|
||||
elapsed = (now - self._circuit_open_since).total_seconds()
|
||||
if elapsed >= 300: # 5분
|
||||
self._circuit_open = False
|
||||
self._circuit_open_since = None
|
||||
alerts.append("✅ [CPU Circuit Breaker CLOSED] 시스템 안정화. 분석 재개.")
|
||||
print("[Monitor] CPU Circuit Breaker CLOSED. 분석 재개.")
|
||||
|
||||
# 2. RAM Check
|
||||
ram = psutil.virtual_memory()
|
||||
if ram.percent > 90:
|
||||
alerts.append(f"⚠️ [RAM High] Usage: {ram.percent}% (Free: {ram.available / 1024**3:.1f}GB)")
|
||||
|
||||
# 3. GPU Check
|
||||
if self.ollama_monitor:
|
||||
gpu_status = self.ollama_monitor.get_gpu_status()
|
||||
temp = gpu_status.get('temp', 0)
|
||||
if temp > 80:
|
||||
alerts.append(f"🔥 [GPU Overheat] Temp: {temp}°C")
|
||||
|
||||
# 알림 전송 (텔레그램 비활성화 - 콘솔 로그만 사용)
|
||||
if alerts:
|
||||
# 콘솔에만 출력
|
||||
for alert in alerts:
|
||||
print(f"[Monitor] {alert}")
|
||||
|
||||
# [비활성화] 텔레그램 알림 - 필요시 재활성화
|
||||
# msg = "🔔 <b>[System Health Alert]</b>\n" + "\n".join(alerts)
|
||||
# if self.messenger:
|
||||
# self.messenger.send_message(msg)
|
||||
211
signal_v1/modules/utils/performance_db.py
Normal file
211
signal_v1/modules/utils/performance_db.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
성과 데이터 영구 저장 - PerformanceDB
|
||||
|
||||
데이터 파일:
|
||||
data/performance/daily_snapshots.json - 일별 자산 스냅샷
|
||||
data/performance/trade_records.json - 강화 매매 기록 (영구 보관)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
PERF_DIR = os.path.join(Config.DATA_DIR, "performance")
|
||||
SNAPSHOTS_FILE = os.path.join(PERF_DIR, "daily_snapshots.json")
|
||||
TRADES_FILE = os.path.join(PERF_DIR, "trade_records.json")
|
||||
|
||||
|
||||
class PerformanceDB:
|
||||
def __init__(self):
|
||||
os.makedirs(PERF_DIR, exist_ok=True)
|
||||
self._snapshots = self._load_json(SNAPSHOTS_FILE, [])
|
||||
self._trades = self._load_json(TRADES_FILE, [])
|
||||
|
||||
def _load_json(self, path, default):
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[PerformanceDB] Load failed {path}: {e}")
|
||||
return default
|
||||
return default
|
||||
|
||||
def _save_json(self, path, data):
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[PerformanceDB] Save failed {path}: {e}")
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 일별 스냅샷
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def save_daily_snapshot(self, total_eval, deposit, holdings_count, benchmark_close=None):
|
||||
"""일별 자산 스냅샷 저장 (하루 1회 호출 권장).
|
||||
|
||||
Args:
|
||||
total_eval (int): 총 평가액 (원)
|
||||
deposit (int): 예수금 (원)
|
||||
holdings_count (int): 보유 종목 수
|
||||
benchmark_close (float|None): KOSPI 현재가 (벤치마크 비교용)
|
||||
"""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# 오늘 이미 저장된 스냅샷이 있으면 업데이트
|
||||
for snap in self._snapshots:
|
||||
if snap.get("date") == today:
|
||||
snap["total_eval"] = total_eval
|
||||
snap["deposit"] = deposit
|
||||
snap["holdings_count"] = holdings_count
|
||||
if benchmark_close is not None:
|
||||
snap["benchmark_kospi_close"] = benchmark_close
|
||||
self._save_json(SNAPSHOTS_FILE, self._snapshots)
|
||||
return
|
||||
|
||||
# 일별/누적 수익률 계산
|
||||
daily_return_pct = 0.0
|
||||
cumulative_return_pct = 0.0
|
||||
|
||||
if self._snapshots:
|
||||
prev_eval = self._snapshots[-1].get("total_eval", 0)
|
||||
if prev_eval > 0:
|
||||
daily_return_pct = (total_eval - prev_eval) / prev_eval * 100
|
||||
|
||||
initial_capital = self.get_initial_capital()
|
||||
if initial_capital and initial_capital > 0:
|
||||
cumulative_return_pct = (total_eval - initial_capital) / initial_capital * 100
|
||||
|
||||
snap = {
|
||||
"date": today,
|
||||
"total_eval": total_eval,
|
||||
"deposit": deposit,
|
||||
"holdings_count": holdings_count,
|
||||
"benchmark_kospi_close": benchmark_close,
|
||||
"daily_return_pct": round(daily_return_pct, 4),
|
||||
"cumulative_return_pct": round(cumulative_return_pct, 4)
|
||||
}
|
||||
self._snapshots.append(snap)
|
||||
self._save_json(SNAPSHOTS_FILE, self._snapshots)
|
||||
print(f"[PerformanceDB] Snapshot saved: {today} "
|
||||
f"total={total_eval:,}원 daily={daily_return_pct:+.2f}%")
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 매매 기록
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def save_trade_record(self, action, ticker, name, qty, price,
|
||||
scores_dict=None, reason="", macro_state="SAFE"):
|
||||
"""매수/매도 기록 저장.
|
||||
|
||||
Args:
|
||||
action (str): "BUY" | "SELL"
|
||||
ticker (str): 종목 코드
|
||||
name (str): 종목명
|
||||
qty (int): 수량
|
||||
price (float): 체결가
|
||||
scores_dict (dict|None): 분석 점수 딕셔너리
|
||||
{tech, sentiment, lstm_score, score, ai_confidence, prediction_change}
|
||||
reason (str): 매매 사유
|
||||
macro_state (str): 매크로 상태 ("SAFE"/"CAUTION"/"DANGER")
|
||||
"""
|
||||
sd = scores_dict or {}
|
||||
now_iso = datetime.now().isoformat()
|
||||
|
||||
trade = {
|
||||
"id": f"{ticker}_{now_iso}",
|
||||
"action": action,
|
||||
"ticker": ticker,
|
||||
"name": name,
|
||||
"qty": qty,
|
||||
"price": price,
|
||||
"timestamp": now_iso,
|
||||
"reason": reason,
|
||||
"macro_state": macro_state,
|
||||
# 점수 (BUY 시에만 의미 있음)
|
||||
"tech_score": float(sd.get("tech", 0.0)),
|
||||
"sentiment_score": float(sd.get("sentiment", 0.0)),
|
||||
"lstm_score": float(sd.get("lstm_score", 0.0)),
|
||||
"total_score": float(sd.get("score", 0.0)),
|
||||
"ai_confidence": float(sd.get("ai_confidence", 0.5)),
|
||||
"ai_prediction_change": float(sd.get("prediction_change", 0.0)),
|
||||
# 매도 후 채워지는 결과 필드
|
||||
"outcome_return_pct": None,
|
||||
"holding_days": None,
|
||||
"closed_at": None
|
||||
}
|
||||
self._trades.append(trade)
|
||||
self._save_json(TRADES_FILE, self._trades)
|
||||
|
||||
def close_trade(self, ticker, sell_price, sell_yield_pct=None):
|
||||
"""가장 최근 미체결 BUY를 찾아 매도 결과를 기록.
|
||||
|
||||
Args:
|
||||
ticker (str): 종목 코드
|
||||
sell_price (float): 매도 체결가
|
||||
sell_yield_pct (float|None): KIS에서 받은 수익률 (보조용)
|
||||
"""
|
||||
for trade in reversed(self._trades):
|
||||
if (trade.get("ticker") == ticker
|
||||
and trade.get("action") == "BUY"
|
||||
and trade.get("outcome_return_pct") is None):
|
||||
|
||||
buy_price = trade.get("price", 0)
|
||||
if buy_price and buy_price > 0:
|
||||
outcome_return_pct = (sell_price - buy_price) / buy_price * 100
|
||||
elif sell_yield_pct is not None:
|
||||
outcome_return_pct = sell_yield_pct
|
||||
else:
|
||||
outcome_return_pct = 0.0
|
||||
|
||||
# 보유일 계산
|
||||
holding_days = 0
|
||||
buy_ts = trade.get("timestamp", "")
|
||||
if buy_ts:
|
||||
try:
|
||||
buy_dt = datetime.fromisoformat(buy_ts)
|
||||
holding_days = (datetime.now() - buy_dt).days
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
trade["outcome_return_pct"] = round(outcome_return_pct, 4)
|
||||
trade["holding_days"] = holding_days
|
||||
trade["closed_at"] = datetime.now().isoformat()
|
||||
self._save_json(TRADES_FILE, self._trades)
|
||||
print(f"[PerformanceDB] Trade closed: {ticker} "
|
||||
f"return={outcome_return_pct:.2f}% holding={holding_days}d")
|
||||
return
|
||||
|
||||
print(f"[PerformanceDB] No open BUY found for {ticker}")
|
||||
|
||||
# ─────────────────────────────────────────
|
||||
# 조회
|
||||
# ─────────────────────────────────────────
|
||||
|
||||
def load_snapshots(self, days=90):
|
||||
"""최근 N일 스냅샷 반환."""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
return [s for s in self._snapshots if s.get("date", "") >= cutoff]
|
||||
|
||||
def load_trades(self, days=90):
|
||||
"""최근 N일 매매 기록 반환."""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
return [t for t in self._trades if t.get("timestamp", "")[:10] >= cutoff]
|
||||
|
||||
def get_initial_capital(self):
|
||||
"""첫 스냅샷 기준 초기 자본 반환."""
|
||||
if self._snapshots:
|
||||
return self._snapshots[0].get("total_eval", 0)
|
||||
return 0
|
||||
|
||||
def get_summary(self):
|
||||
"""간단한 현황 딕셔너리 반환 (디버깅용)."""
|
||||
return {
|
||||
"total_snapshots": len(self._snapshots),
|
||||
"total_trades": len(self._trades),
|
||||
"closed_trades": sum(1 for t in self._trades
|
||||
if t.get("outcome_return_pct") is not None),
|
||||
"initial_capital": self.get_initial_capital()
|
||||
}
|
||||
183
signal_v1/modules/utils/process_tracker.py
Normal file
183
signal_v1/modules/utils/process_tracker.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
프로세스 생명주기 관리
|
||||
- 메모리 기반 PID 관리 (pids.txt 폐기)
|
||||
- Watchdog 헬스체크
|
||||
- 자동 재시작 (최대 3회)
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from multiprocessing.shared_memory import SharedMemory
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
|
||||
class ProcessTracker:
|
||||
"""메모리 기반 프로세스 추적기"""
|
||||
|
||||
# 클래스 변수: 등록된 프로세스 정보
|
||||
_processes = {} # {name: pid}
|
||||
_lock = threading.Lock()
|
||||
|
||||
# 하위 호환: 기존 pids.txt 정리용
|
||||
FILE_PATH = "pids.txt"
|
||||
|
||||
@staticmethod
|
||||
def register(name):
|
||||
"""현재 프로세스 등록 (메모리 기반)"""
|
||||
pid = os.getpid()
|
||||
with ProcessTracker._lock:
|
||||
ProcessTracker._processes[name] = pid
|
||||
print(f"[Process] Registered: {name} (PID: {pid})")
|
||||
|
||||
@staticmethod
|
||||
def unregister(name):
|
||||
"""프로세스 등록 해제"""
|
||||
with ProcessTracker._lock:
|
||||
ProcessTracker._processes.pop(name, None)
|
||||
|
||||
@staticmethod
|
||||
def get_all():
|
||||
"""등록된 모든 프로세스 반환"""
|
||||
with ProcessTracker._lock:
|
||||
return dict(ProcessTracker._processes)
|
||||
|
||||
@staticmethod
|
||||
def check_and_kill_zombies():
|
||||
"""이전 실행의 좀비 프로세스 정리 + stale SharedMemory 정리"""
|
||||
# 1. pids.txt 기반 좀비 정리 (하위 호환)
|
||||
if os.path.exists(ProcessTracker.FILE_PATH):
|
||||
try:
|
||||
import psutil
|
||||
current_pid = os.getpid()
|
||||
|
||||
with open(ProcessTracker.FILE_PATH, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
killed_count = 0
|
||||
for line in lines:
|
||||
if ":" not in line or "Running Processes" in line:
|
||||
continue
|
||||
try:
|
||||
pid = int(line.split(":")[0].strip())
|
||||
if pid == current_pid:
|
||||
continue
|
||||
if psutil.pid_exists(pid):
|
||||
proc = psutil.Process(pid)
|
||||
if "python" in proc.name().lower():
|
||||
print(f"[Process] Killing zombie: PID {pid} ({line.strip()})")
|
||||
proc.kill()
|
||||
killed_count += 1
|
||||
except (ValueError, psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
if killed_count > 0:
|
||||
print(f"[Process] Cleaned up {killed_count} zombie processes.")
|
||||
except Exception as e:
|
||||
print(f"[Process] Zombie cleanup failed: {e}")
|
||||
|
||||
# pids.txt 삭제 (더 이상 사용하지 않음)
|
||||
try:
|
||||
os.remove(ProcessTracker.FILE_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Stale SharedMemory 정리
|
||||
try:
|
||||
shm = SharedMemory(name=Config.SHM_NAME, create=False)
|
||||
shm.close()
|
||||
shm.unlink()
|
||||
print(f"[Process] Cleaned stale SharedMemory: {Config.SHM_NAME}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def clear():
|
||||
"""등록 정보 초기화"""
|
||||
with ProcessTracker._lock:
|
||||
ProcessTracker._processes.clear()
|
||||
|
||||
|
||||
class ProcessWatchdog:
|
||||
"""자식 프로세스 감시 및 자동 재시작"""
|
||||
|
||||
def __init__(self, shutdown_event=None):
|
||||
self.shutdown_event = shutdown_event
|
||||
self._watched = {} # {name: {process, target, args, restart_count}}
|
||||
self._thread = None
|
||||
self._running = False
|
||||
|
||||
def watch(self, name, process, target, args=()):
|
||||
"""프로세스를 감시 대상에 등록"""
|
||||
self._watched[name] = {
|
||||
'process': process,
|
||||
'target': target,
|
||||
'args': args,
|
||||
'restart_count': 0
|
||||
}
|
||||
|
||||
def start(self):
|
||||
"""Watchdog 스레드 시작"""
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._watchdog_loop, daemon=True)
|
||||
self._thread.start()
|
||||
print(f"[Watchdog] Started (interval: {Config.WATCHDOG_INTERVAL}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Watchdog 중지"""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def get_process(self, name):
|
||||
"""감시 중인 프로세스 반환"""
|
||||
entry = self._watched.get(name)
|
||||
return entry['process'] if entry else None
|
||||
|
||||
def _watchdog_loop(self):
|
||||
"""주기적으로 자식 프로세스 상태 확인"""
|
||||
import multiprocessing
|
||||
|
||||
while self._running:
|
||||
if self.shutdown_event and self.shutdown_event.is_set():
|
||||
break
|
||||
|
||||
for name, entry in list(self._watched.items()):
|
||||
proc = entry['process']
|
||||
|
||||
if proc.is_alive():
|
||||
continue
|
||||
|
||||
# 프로세스가 종료됨
|
||||
exit_code = proc.exitcode
|
||||
restart_count = entry['restart_count']
|
||||
|
||||
if restart_count >= Config.MAX_RESTART_COUNT:
|
||||
print(f"[Watchdog] {name} crashed (exit={exit_code}). "
|
||||
f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")
|
||||
continue
|
||||
|
||||
print(f"[Watchdog] {name} crashed (exit={exit_code}). "
|
||||
f"Restarting... ({restart_count + 1}/{Config.MAX_RESTART_COUNT})")
|
||||
|
||||
try:
|
||||
new_proc = multiprocessing.Process(
|
||||
target=entry['target'],
|
||||
args=entry['args']
|
||||
)
|
||||
new_proc.start()
|
||||
entry['process'] = new_proc
|
||||
entry['restart_count'] = restart_count + 1
|
||||
print(f"[Watchdog] {name} restarted (new PID: {new_proc.pid})")
|
||||
except Exception as e:
|
||||
print(f"[Watchdog] Failed to restart {name}: {e}")
|
||||
|
||||
# 인터벌 대기 (shutdown_event 체크하면서)
|
||||
for _ in range(Config.WATCHDOG_INTERVAL):
|
||||
if not self._running:
|
||||
break
|
||||
if self.shutdown_event and self.shutdown_event.is_set():
|
||||
break
|
||||
time.sleep(1)
|
||||
Reference in New Issue
Block a user