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:
2026-05-16 03:00:11 +09:00
parent 42b91d03cf
commit 7ea1a21487
39 changed files with 722 additions and 691 deletions

View File

@@ -0,0 +1,2 @@
# Initialize modules package

View 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

View 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

View 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()

View 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

View 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

View 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']}")

View 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})"

View 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

View 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
View 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
View 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

View 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

View 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

View 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()

View 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,
}

View 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

View 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()

View 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()

View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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}")

View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ─────────────────────────────────────────────
# 스킬별 포맷터
# ─────────────────────────────────────────────
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>")

View 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)

View 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)
}

View 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)

View 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

View 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

View 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)

View 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()
}

View 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)