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:
130
signal_v1/modules/strategy/daily_ledger.py
Normal file
130
signal_v1/modules/strategy/daily_ledger.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
일일 거래 장부 (DailyLedger) — v3.2
|
||||
|
||||
bot.py에 흩어져 있던 당일 상태를 한 객체로 집약:
|
||||
- 당일 누적 매수금액 (KIS T+2 미차감 보완용)
|
||||
- 연속 손절 카운터 + 매수 일시중단 타이머
|
||||
- 미매도 종목의 매수 신호 점수 (앙상블 학습용)
|
||||
- 일별 스냅샷/주간평가 플래그
|
||||
|
||||
날짜가 바뀌면 reset_if_new_day()가 자동 초기화.
|
||||
순수 객체로 구현 — 외부 I/O 없음 → 단위 테스트 가능.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, date as date_cls
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyLedger:
|
||||
# ── 당일 매수 회계 ──
|
||||
today_buy_total: int = 0
|
||||
today_buy_date: Optional[date_cls] = None
|
||||
|
||||
# ── 연속 손절 / 매수 일시 중단 ──
|
||||
consecutive_stop_losses: int = 0
|
||||
buy_paused_until: Optional[datetime] = None
|
||||
stop_loss_pause_threshold: int = 3
|
||||
stop_loss_pause_minutes: int = 30
|
||||
|
||||
# ── 앙상블 학습용: 미매도 종목의 매수 신호 점수 ──
|
||||
buy_scores: Dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# ── 일일 플래그 ──
|
||||
snapshot_taken: bool = False
|
||||
weekly_eval_sent: bool = False
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 날짜 전환
|
||||
# ──────────────────────────────────────────────
|
||||
def reset_if_new_day(self, now: datetime) -> bool:
|
||||
"""
|
||||
오늘 날짜 기준으로 상태 초기화. 이미 오늘 자로 초기화됐으면 no-op.
|
||||
|
||||
Returns:
|
||||
True — 실제로 초기화를 수행한 경우
|
||||
False — 같은 날이라 그대로 둔 경우
|
||||
"""
|
||||
today = now.date()
|
||||
if self.today_buy_date == today:
|
||||
return False
|
||||
self.today_buy_total = 0
|
||||
self.today_buy_date = today
|
||||
self.buy_scores.clear()
|
||||
self.snapshot_taken = False
|
||||
self.weekly_eval_sent = False
|
||||
# 연속 손절 카운터 / 일시중단 타이머는 날짜 전환 시에만 초기화
|
||||
self.consecutive_stop_losses = 0
|
||||
self.buy_paused_until = None
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 매수 / 매도 기록
|
||||
# ──────────────────────────────────────────────
|
||||
def record_buy(self, ticker: str, amount: int, scores: dict) -> None:
|
||||
"""매수 체결 기록. amount는 집행 금액(원), scores는 앙상블 신호."""
|
||||
self.today_buy_total += int(amount)
|
||||
self.buy_scores[ticker] = dict(scores)
|
||||
|
||||
def pop_buy_scores(self, ticker: str) -> Optional[dict]:
|
||||
"""매도 체결 시 앙상블 학습을 위해 매수 당시 신호를 반환하고 제거."""
|
||||
return self.buy_scores.pop(ticker, None)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 손절 관리
|
||||
# ──────────────────────────────────────────────
|
||||
def record_sell_outcome(self, outcome_pct: float, now: datetime) -> bool:
|
||||
"""
|
||||
매도 결과를 반영해 연속 손절 카운터 업데이트.
|
||||
|
||||
Returns:
|
||||
True — 임계치 도달 → 매수 일시중단 활성화됨
|
||||
False — 임계치 미도달
|
||||
"""
|
||||
if outcome_pct < 0:
|
||||
self.consecutive_stop_losses += 1
|
||||
if self.consecutive_stop_losses >= self.stop_loss_pause_threshold:
|
||||
self.buy_paused_until = now + timedelta(
|
||||
minutes=self.stop_loss_pause_minutes
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.consecutive_stop_losses = 0
|
||||
return False
|
||||
|
||||
def is_buy_paused(self, now: datetime) -> bool:
|
||||
"""
|
||||
매수 일시중단 상태 조회. 만료되면 자동 해제 + 카운터 리셋.
|
||||
"""
|
||||
if self.buy_paused_until is None:
|
||||
return False
|
||||
if now >= self.buy_paused_until:
|
||||
self.buy_paused_until = None
|
||||
self.consecutive_stop_losses = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 예수금 계산 (KIS T+2 보완)
|
||||
# ──────────────────────────────────────────────
|
||||
def effective_today_buy(self, kis_today_buy: int) -> int:
|
||||
"""
|
||||
KIS API가 반환한 당일 매수금(`thdt_buy_amt`)과
|
||||
로컬 누적값 중 더 큰 값을 신뢰.
|
||||
(모의투자는 T+2 미차감으로 인해 과소 보고되는 경우 있음)
|
||||
"""
|
||||
return max(int(kis_today_buy or 0), self.today_buy_total)
|
||||
|
||||
def available_deposit(self, raw_deposit: int, max_daily_buy_ratio: float,
|
||||
kis_today_buy: int = 0) -> int:
|
||||
"""
|
||||
당일 사용 가능한 예수금 계산.
|
||||
|
||||
max_daily_buy = raw_deposit × ratio
|
||||
avail = min(raw_deposit, max_daily_buy) − effective_today_buy
|
||||
"""
|
||||
if raw_deposit <= 0:
|
||||
return 0
|
||||
max_daily_buy = int(raw_deposit * max_daily_buy_ratio)
|
||||
used = self.effective_today_buy(kis_today_buy)
|
||||
return max(0, min(raw_deposit, max_daily_buy) - used)
|
||||
571
signal_v1/modules/strategy/process.py
Normal file
571
signal_v1/modules/strategy/process.py
Normal file
@@ -0,0 +1,571 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import numpy as np
|
||||
from modules.services.llm_client import get_llm_client
|
||||
from modules.analysis.technical import TechnicalAnalyzer
|
||||
from modules.analysis.deep_learning import ModelRegistry
|
||||
from modules.analysis.market_regime import MarketRegimeDetector
|
||||
from modules.analysis.ai_council import get_council
|
||||
from modules.analysis.ensemble import get_ensemble
|
||||
from modules.config import Config
|
||||
|
||||
# AI Council 마지막 호출 시각 캐시 (종목별, 과다 호출 방지)
|
||||
_council_last_call: dict = {}
|
||||
|
||||
|
||||
def get_predictor(ticker=None):
|
||||
"""워커 프로세스 내에서 ModelRegistry로 종목별 PricePredictor 관리"""
|
||||
registry = ModelRegistry.get_instance()
|
||||
return registry.get_predictor(ticker or "default")
|
||||
|
||||
|
||||
def get_ollama():
|
||||
"""LLMClient 싱글톤 반환 (Gemini 우선, Ollama 폴백)"""
|
||||
return get_llm_client()
|
||||
|
||||
|
||||
def calculate_position_size(total_capital, current_price, volatility, score, ai_confidence,
|
||||
max_per_stock=3000000, ticker=None):
|
||||
"""
|
||||
[v3.1] Modified Kelly Criterion 기반 포지션 사이징
|
||||
|
||||
핵심 원칙:
|
||||
1. Kelly Fraction: f* = (p*b - q) / b (과거 실전 승률 + 손익비 기반)
|
||||
- 데이터 부족 시 보수적 기본값 8% 사용
|
||||
- Half-Kelly 적용으로 변동성 과대추정 보완
|
||||
2. 변동성 조절: ATR 기반 변동성에 따라 Kelly 비중 추가 조절
|
||||
3. 확신도 조절: 앙상블 score에 따른 최종 배수
|
||||
4. AI 신뢰도 가산: LSTM confidence 기반 (상한 0.80 반영)
|
||||
5. 상한: min(종목당 최대, 자산의 20%, 실제 자산)
|
||||
|
||||
Returns:
|
||||
int: 매수 수량 (0이면 매수 안 함)
|
||||
"""
|
||||
if current_price <= 0 or total_capital <= 0:
|
||||
return 0
|
||||
|
||||
# 1. Kelly Fraction 기반 기본 투자 비중
|
||||
ensemble = get_ensemble()
|
||||
kelly_f = ensemble.get_kelly_fraction(ticker=ticker, half_kelly=True)
|
||||
base_invest = total_capital * kelly_f
|
||||
|
||||
# 2. 변동성 조절 계수 (ATR% 기반, 변동성 높을수록 축소)
|
||||
if volatility <= 1.0:
|
||||
vol_factor = 1.2
|
||||
elif volatility <= 2.0:
|
||||
vol_factor = 1.0
|
||||
elif volatility <= 3.0:
|
||||
vol_factor = 0.7
|
||||
elif volatility <= 5.0:
|
||||
vol_factor = 0.45
|
||||
else:
|
||||
vol_factor = 0.3
|
||||
|
||||
# 3. 앙상블 확신도 조절 계수 (score 기반)
|
||||
if score >= 0.85:
|
||||
conf_factor = 2.0
|
||||
elif score >= 0.75:
|
||||
conf_factor = 1.5
|
||||
elif score >= 0.65:
|
||||
conf_factor = 1.0
|
||||
else:
|
||||
conf_factor = 0.5
|
||||
|
||||
# 4. AI 신뢰도 가산 (LSTM confidence 상한 0.80 반영)
|
||||
ai_bonus = 1.0
|
||||
if ai_confidence >= 0.75:
|
||||
ai_bonus = 1.2
|
||||
elif ai_confidence >= 0.65:
|
||||
ai_bonus = 1.1
|
||||
|
||||
# 5. 최종 투자금 계산
|
||||
invest_amount = base_invest * vol_factor * conf_factor * ai_bonus
|
||||
|
||||
invest_amount = min(invest_amount, max_per_stock) # 종목당 최대
|
||||
invest_amount = min(invest_amount, total_capital * 0.20) # 자산 20% 상한
|
||||
invest_amount = min(invest_amount, total_capital)
|
||||
|
||||
qty = int(invest_amount / current_price)
|
||||
kelly_pct = invest_amount / total_capital * 100 if total_capital > 0 else 0
|
||||
print(f" [Kelly] f={kelly_f:.2%} invest={invest_amount:,.0f}won ({kelly_pct:.1f}%) qty={qty}")
|
||||
return max(0, qty)
|
||||
|
||||
|
||||
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
||||
macro_status=None, holding_info=None, total_capital=None):
|
||||
"""
|
||||
[v3.1] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
|
||||
|
||||
[v3.1 개선사항]
|
||||
1. AdaptiveEnsemble 연동: 하드코딩 가중치 → 학습 기반 동적 가중치
|
||||
2. Kelly Criterion 기반 포지션 사이징 (calculate_position_size)
|
||||
3. 파일 mtime 동기화: 메인 프로세스의 record_trade 결과를 워커에 반영
|
||||
[v3.0 기능 유지]
|
||||
4. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
|
||||
5. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
|
||||
6. 강화된 LLM 프롬프트
|
||||
"""
|
||||
try:
|
||||
# [v3.1] 메인 프로세스가 갱신한 앙상블 가중치 파일 감지 → 재로드
|
||||
get_ensemble().reload_if_stale()
|
||||
# OHLCV 데이터 분리 (하위호환: list 형태도 허용)
|
||||
if isinstance(ohlcv_data, dict):
|
||||
prices = ohlcv_data.get('close', [])
|
||||
high_prices = ohlcv_data.get('high') or None
|
||||
low_prices = ohlcv_data.get('low') or None
|
||||
volume_history = ohlcv_data.get('volume') or None
|
||||
open_prices = ohlcv_data.get('open') or None
|
||||
else:
|
||||
# 하위 호환: 기존 close 리스트
|
||||
prices = ohlcv_data if isinstance(ohlcv_data, list) else []
|
||||
high_prices = None
|
||||
low_prices = None
|
||||
volume_history = None
|
||||
open_prices = None
|
||||
|
||||
# volume이 모두 0이거나 비어있으면 None 처리
|
||||
if volume_history and all(v == 0 for v in volume_history):
|
||||
volume_history = None
|
||||
|
||||
print(f"⚙️ [Bot Process] Analyzing {ticker} ({len(prices)} candles, "
|
||||
f"OHLCV={'yes' if high_prices else 'close-only'}, "
|
||||
f"Vol={'yes' if volume_history else 'no'})...")
|
||||
|
||||
# ===== 1. 기술적 지표 계산 =====
|
||||
current_price = prices[-1] if prices else 0
|
||||
tech_score, rsi, volatility, vol_ratio, ma_info = TechnicalAnalyzer.get_technical_score(
|
||||
current_price, prices, volume_history=volume_history)
|
||||
|
||||
# ===== 2. ATR 기반 동적 손절/익절 (실제 고가/저가 사용) =====
|
||||
sl_tp = TechnicalAnalyzer.calculate_dynamic_sl_tp(
|
||||
prices, high_prices=high_prices, low_prices=low_prices)
|
||||
|
||||
# ===== 3. 볼린저밴드 위치 계산 =====
|
||||
bb_upper, bb_mid, bb_lower = TechnicalAnalyzer.calculate_bollinger_bands(prices)
|
||||
if bb_upper > bb_lower:
|
||||
bb_pos = (current_price - bb_lower) / (bb_upper - bb_lower) # 0=하단, 1=상단
|
||||
if bb_pos <= 0.2:
|
||||
bb_zone = "하단(과매도)"
|
||||
elif bb_pos >= 0.8:
|
||||
bb_zone = "상단(과매수)"
|
||||
else:
|
||||
bb_zone = f"중간({bb_pos:.0%})"
|
||||
else:
|
||||
bb_pos = 0.5
|
||||
bb_zone = "중간"
|
||||
|
||||
# ===== 4. LSTM 주가 예측 (ModelRegistry 사용) =====
|
||||
lstm_predictor = get_predictor(ticker)
|
||||
if lstm_predictor:
|
||||
lstm_predictor.training_status['current_ticker'] = ticker
|
||||
|
||||
# LSTM에 전달할 OHLCV 딕셔너리 구성
|
||||
lstm_ohlcv = {
|
||||
'close': prices,
|
||||
'open': open_prices or prices,
|
||||
'high': high_prices or prices,
|
||||
'low': low_prices or prices,
|
||||
'volume': volume_history or []
|
||||
}
|
||||
pred_result = lstm_predictor.train_and_predict(lstm_ohlcv, ticker=ticker)
|
||||
|
||||
lstm_score = 0.5
|
||||
ai_confidence = 0.5
|
||||
ai_loss = 1.0
|
||||
|
||||
if pred_result:
|
||||
ai_confidence = pred_result.get('confidence', 0.5)
|
||||
ai_loss = pred_result.get('loss', 1.0)
|
||||
|
||||
change_magnitude = min(abs(pred_result['change_rate']), 5.0) / 5.0
|
||||
|
||||
if pred_result['trend'] == 'UP':
|
||||
lstm_score = 0.5 + (change_magnitude * ai_confidence * 0.4)
|
||||
else:
|
||||
lstm_score = 0.5 - (change_magnitude * ai_confidence * 0.4)
|
||||
|
||||
lstm_score = max(0.0, min(1.0, lstm_score))
|
||||
|
||||
# ===== 5. 수급 분석 (외인/기관) =====
|
||||
investor_score = 0.0
|
||||
frgn_net_buy = 0
|
||||
orgn_net_buy = 0
|
||||
consecutive_frgn_buy = 0
|
||||
consecutive_orgn_buy = 0
|
||||
|
||||
if investor_trend:
|
||||
for day in investor_trend:
|
||||
frgn_net_buy += day['foreigner']
|
||||
orgn_net_buy += day['institutional']
|
||||
|
||||
# 연속 매수일 수: 가장 최근부터 역순으로 연속된 양수 일수만 카운트
|
||||
for day in reversed(investor_trend):
|
||||
if day['foreigner'] > 0:
|
||||
consecutive_frgn_buy += 1
|
||||
else:
|
||||
break
|
||||
for day in reversed(investor_trend):
|
||||
if day['institutional'] > 0:
|
||||
consecutive_orgn_buy += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if frgn_net_buy > 0:
|
||||
investor_score += 0.03
|
||||
if consecutive_frgn_buy >= 3:
|
||||
investor_score += 0.04
|
||||
if consecutive_frgn_buy >= 5:
|
||||
investor_score += 0.03
|
||||
|
||||
if orgn_net_buy > 0:
|
||||
investor_score += 0.02
|
||||
if consecutive_orgn_buy >= 3:
|
||||
investor_score += 0.03
|
||||
|
||||
if frgn_net_buy > 0 and orgn_net_buy > 0:
|
||||
investor_score += 0.03
|
||||
print(f" 💰 [Investor] Both Foreign & Institutional Buying!")
|
||||
|
||||
# ===== 6. AI 뉴스 분석 (강화된 프롬프트) =====
|
||||
if pred_result:
|
||||
pred_price = pred_result.get('predicted', 0)
|
||||
pred_change = pred_result.get('change_rate', 0)
|
||||
else:
|
||||
pred_price = current_price
|
||||
pred_change = 0.0
|
||||
|
||||
news_summary = "; ".join(
|
||||
[n.get('title', '') for n in (news_items or [])[:3] if n.get('title')]
|
||||
) or "뉴스 없음"
|
||||
|
||||
# 거시경제 상태
|
||||
macro_state = macro_status.get('status', 'SAFE') if macro_status else 'SAFE'
|
||||
|
||||
# 거래량 급증 여부
|
||||
vol_surge = "급증(x{:.1f})".format(vol_ratio) if vol_ratio >= 2.0 else "정상"
|
||||
|
||||
# 보유종목 수익률
|
||||
holding_yield_str = ""
|
||||
if holding_info and holding_info.get('qty', 0) > 0:
|
||||
yld = holding_info.get('yield', 0.0)
|
||||
holding_yield_str = f" | 보유수익률={yld:+.1f}%"
|
||||
|
||||
ollama = get_ollama()
|
||||
prompt = (
|
||||
f"Korean stock analyst. JSON only: {{\"sentiment_score\":0.0-1.0,\"reason\":\"1 sentence\"}}\n"
|
||||
f"Stock {ticker} ₩{current_price:,.0f}{holding_yield_str}\n"
|
||||
f"Market={macro_state} | "
|
||||
f"Tech={tech_score:.2f} RSI={rsi:.1f} MA={ma_info['trend']} ADX={ma_info.get('adx',20):.0f} "
|
||||
f"MTF={ma_info.get('mtf_alignment','N/A')}\n"
|
||||
f"BB={bb_zone} | AI={pred_change:+.2f}% conf={ai_confidence:.0%} | "
|
||||
f"Vol={volatility:.1f}% VolRatio={vol_surge}\n"
|
||||
f"Flow: Frgn={frgn_net_buy:+,}({consecutive_frgn_buy}d) "
|
||||
f"Inst={orgn_net_buy:+,}({consecutive_orgn_buy}d)\n"
|
||||
f"News: {news_summary}"
|
||||
)
|
||||
ai_resp = ollama.request_inference(prompt)
|
||||
sentiment_score = 0.5
|
||||
ai_reason = ""
|
||||
try:
|
||||
data = json.loads(ai_resp)
|
||||
sentiment_score = float(data.get("sentiment_score", 0.5))
|
||||
sentiment_score = max(0.0, min(1.0, sentiment_score))
|
||||
ai_reason = data.get("reason", "")
|
||||
except Exception:
|
||||
print(f" ⚠️ AI response parse failed, using neutral (0.5)")
|
||||
|
||||
# ===== 7. 통합 점수 (AdaptiveEnsemble v3.1) =====
|
||||
# 하드코딩 가중치 → 학습 기반 동적 가중치 (과거 매매 결과 반영)
|
||||
adx_val = ma_info.get('adx', 20)
|
||||
|
||||
ensemble = get_ensemble()
|
||||
weights = ensemble.get_weights(
|
||||
ticker=ticker,
|
||||
adx=adx_val,
|
||||
macro_state=macro_state,
|
||||
ai_confidence=ai_confidence
|
||||
)
|
||||
print(f" [Ensemble] tech={weights.tech:.2f} news={weights.sentiment:.2f} "
|
||||
f"lstm={weights.lstm:.2f} (adx={adx_val:.0f} conf={ai_confidence:.2f})")
|
||||
|
||||
total_score = ensemble.compute_ensemble_score(
|
||||
tech_score=tech_score,
|
||||
sentiment_score=sentiment_score,
|
||||
lstm_score=lstm_score,
|
||||
investor_score=investor_score,
|
||||
weights=weights
|
||||
)
|
||||
|
||||
# ===== 7.5. 시장 레짐 감지 (코스피 수준 기반) =====
|
||||
kospi_price = 0.0
|
||||
kospi_change_val = 0.0
|
||||
regime_analysis = None
|
||||
if macro_status:
|
||||
kospi_info = macro_status.get('indicators', {}).get('KOSPI', {})
|
||||
kospi_price = float(kospi_info.get('price', 0) or 0)
|
||||
kospi_change_val = float(kospi_info.get('change', 0) or 0)
|
||||
|
||||
if Config.MARKET_REGIME_ENABLED and kospi_price > 0:
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi_price, kospi_change_val)
|
||||
print(
|
||||
f" 📈 [Regime] {MarketRegimeDetector.get_regime_label(kospi_price)} "
|
||||
f"risk={regime_analysis.risk_level} "
|
||||
f"buy_adj={regime_analysis.buy_threshold_adj:+.2f} "
|
||||
f"pos=x{regime_analysis.position_size_adj:.2f}"
|
||||
)
|
||||
|
||||
# ===== 8. 시장 상황별 동적 임계값 =====
|
||||
buy_threshold = 0.60
|
||||
sell_threshold = 0.30
|
||||
danger_force_sell = False # DANGER 긴급 매도 플래그
|
||||
|
||||
if macro_status:
|
||||
if macro_state == 'DANGER':
|
||||
buy_threshold = 999.0
|
||||
sell_threshold = 0.35 # 이전 0.45에서 하향 (더 적극적 손절)
|
||||
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold lowered to 0.35")
|
||||
# 보유 중이고 손실이면 즉시 매도 플래그
|
||||
if holding_info and holding_info.get('qty', 0) > 0:
|
||||
hy = holding_info.get('yield', 0.0)
|
||||
if hy < -3.0:
|
||||
danger_force_sell = True
|
||||
print(f" 🚨 [DANGER + Loss {hy:.1f}%] Emergency Sell Triggered")
|
||||
elif macro_state == 'CAUTION':
|
||||
buy_threshold = 0.72
|
||||
sell_threshold = 0.38
|
||||
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
|
||||
|
||||
# 레짐 기반 임계값 추가 조정 (거시경제 판단 이후 적용)
|
||||
if regime_analysis and macro_state != 'DANGER':
|
||||
buy_threshold = round(
|
||||
max(0.55, buy_threshold + regime_analysis.buy_threshold_adj), 3
|
||||
)
|
||||
|
||||
# ===== 9. 매매 결정 =====
|
||||
decision = "HOLD"
|
||||
decision_reason = ""
|
||||
|
||||
# DANGER 긴급 매도 (손실 보유종목)
|
||||
if danger_force_sell:
|
||||
decision = "SELL"
|
||||
decision_reason = f"Emergency DANGER Market + Loss ({holding_info.get('yield', 0.0):.1f}%)"
|
||||
|
||||
if holding_info:
|
||||
holding_yield = holding_info.get('yield', 0.0)
|
||||
holding_qty = holding_info.get('qty', 0)
|
||||
peak_price = holding_info.get('peak_price', current_price)
|
||||
|
||||
if holding_qty > 0:
|
||||
if holding_yield <= sl_tp['stop_loss_pct']:
|
||||
decision = "SELL"
|
||||
decision_reason = f"Dynamic Stop Loss ({holding_yield:.1f}% <= {sl_tp['stop_loss_pct']:.1f}%)"
|
||||
|
||||
elif holding_yield >= sl_tp['take_profit_pct']:
|
||||
decision = "SELL"
|
||||
decision_reason = f"Dynamic Take Profit ({holding_yield:.1f}% >= {sl_tp['take_profit_pct']:.1f}%)"
|
||||
|
||||
elif peak_price > 0:
|
||||
drop_from_peak = ((current_price - peak_price) / peak_price) * 100
|
||||
if drop_from_peak <= -sl_tp['trailing_stop_pct'] and holding_yield > 2.0:
|
||||
decision = "SELL"
|
||||
decision_reason = (f"Trailing Stop ({drop_from_peak:.1f}% from peak, "
|
||||
f"threshold: -{sl_tp['trailing_stop_pct']:.1f}%)")
|
||||
|
||||
if decision == "HOLD" and total_score <= sell_threshold:
|
||||
decision = "SELL"
|
||||
decision_reason = f"Analysis Signal (Score: {total_score:.2f} <= {sell_threshold:.2f})"
|
||||
|
||||
if decision == "HOLD" and adx_val >= 30:
|
||||
mtf_align = ma_info.get('mtf_alignment', '')
|
||||
if mtf_align == 'STRONG_BEAR' and holding_yield < 0:
|
||||
decision = "SELL"
|
||||
decision_reason = f"Strong Bear Trend Reversal (MTF: {mtf_align})"
|
||||
|
||||
# --- 매수 판단 ---
|
||||
if decision == "HOLD":
|
||||
strong_signal = False
|
||||
strong_reason = ""
|
||||
|
||||
if tech_score >= 0.75 and lstm_score >= 0.6 and sentiment_score >= 0.6:
|
||||
strong_signal = True
|
||||
strong_reason = "Triple Confirmation (Tech+AI+News)"
|
||||
elif lstm_score >= 0.78 and ai_confidence >= 0.75 and adx_val >= 25:
|
||||
strong_signal = True
|
||||
strong_reason = f"High Confidence AI + Strong Trend (ADX={adx_val:.0f})"
|
||||
elif investor_score >= 0.10 and tech_score >= 0.60 and total_score >= 0.60:
|
||||
strong_signal = True
|
||||
strong_reason = "Institutional Buying + Good Fundamentals"
|
||||
elif ma_info.get('mtf_alignment') == 'STRONG_BULL' and tech_score >= 0.60:
|
||||
strong_signal = True
|
||||
strong_reason = f"Strong Multi-Timeframe Bullish + Tech {tech_score:.2f}"
|
||||
|
||||
if strong_signal and total_score >= buy_threshold - 0.05:
|
||||
decision = "BUY"
|
||||
decision_reason = strong_reason
|
||||
print(f" 🎯 [{strong_reason}] → BUY!")
|
||||
elif total_score >= buy_threshold:
|
||||
decision = "BUY"
|
||||
decision_reason = f"Score {total_score:.2f} >= threshold {buy_threshold:.2f}"
|
||||
|
||||
# ===== 10. 포지션 사이징 =====
|
||||
# total_capital: 호출 측에서 실제 잔고 전달 (없으면 보수적 기본값 5M)
|
||||
_capital = total_capital if (total_capital and total_capital > 0) else 5_000_000
|
||||
suggested_qty = 0
|
||||
if decision == "BUY":
|
||||
suggested_qty = calculate_position_size(
|
||||
total_capital=_capital,
|
||||
current_price=current_price,
|
||||
volatility=volatility,
|
||||
score=total_score,
|
||||
ai_confidence=ai_confidence,
|
||||
ticker=ticker
|
||||
)
|
||||
if suggested_qty == 0:
|
||||
decision = "HOLD"
|
||||
decision_reason = "Position size too small"
|
||||
|
||||
# 레짐 기반 포지션 크기 조정 (이미 계산된 수량에 배수 적용)
|
||||
if regime_analysis and suggested_qty > 0:
|
||||
adjusted_qty = int(suggested_qty * regime_analysis.position_size_adj)
|
||||
if adjusted_qty != suggested_qty:
|
||||
print(f" 📐 [Regime] 포지션 조정: {suggested_qty} → {adjusted_qty}주 "
|
||||
f"(x{regime_analysis.position_size_adj:.2f})")
|
||||
suggested_qty = max(0, adjusted_qty)
|
||||
if suggested_qty == 0:
|
||||
decision = "HOLD"
|
||||
decision_reason = "Regime position size adjustment → 0"
|
||||
|
||||
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} "
|
||||
f"LSTM={lstm_score:.2f} Inv={investor_score:.2f} → "
|
||||
f"Total={total_score:.2f} [{decision}]"
|
||||
f"{f' ({decision_reason})' if decision_reason else ''}")
|
||||
|
||||
# ===== 11. AI 전문가 회의 (선택적, Config.AI_COUNCIL_ENABLED) =====
|
||||
council_decision = None
|
||||
if Config.AI_COUNCIL_ENABLED:
|
||||
now = time.time()
|
||||
last_call = _council_last_call.get(ticker, 0)
|
||||
if now - last_call >= Config.AI_COUNCIL_MIN_INTERVAL:
|
||||
_council_last_call[ticker] = now
|
||||
council_data = {
|
||||
"current_price": current_price,
|
||||
"kospi_price": kospi_price,
|
||||
"macro_state": macro_state,
|
||||
"tech_score": tech_score,
|
||||
"rsi": rsi,
|
||||
"adx": adx_val,
|
||||
"volatility": volatility,
|
||||
"bb_zone": bb_zone,
|
||||
"mtf_alignment": ma_info.get('mtf_alignment', 'N/A'),
|
||||
"lstm_predicted": (
|
||||
pred_result.get('predicted', current_price)
|
||||
if pred_result else current_price
|
||||
),
|
||||
"lstm_change_rate": (
|
||||
pred_result.get('change_rate', 0) if pred_result else 0
|
||||
),
|
||||
"ai_confidence": ai_confidence,
|
||||
"lstm_score": lstm_score,
|
||||
"sentiment_score": sentiment_score,
|
||||
"investor_score": investor_score,
|
||||
"frgn_net_buy": frgn_net_buy,
|
||||
"consecutive_frgn_buy": consecutive_frgn_buy,
|
||||
"is_holding": (
|
||||
holding_info.get('qty', 0) > 0 if holding_info else False
|
||||
),
|
||||
"holding_yield": (
|
||||
holding_info.get('yield', 0.0) if holding_info else 0.0
|
||||
),
|
||||
"total_score": total_score,
|
||||
}
|
||||
try:
|
||||
council = get_council(get_ollama())
|
||||
council_decision = council.convene(
|
||||
ticker, council_data,
|
||||
regime_analysis=regime_analysis,
|
||||
fast_mode=Config.AI_COUNCIL_FAST_MODE,
|
||||
)
|
||||
# 모델 교체 권고 경고 출력
|
||||
if council_decision.model_replacement_recommended:
|
||||
print(
|
||||
f" ⚠️ [Council] 모델 교체 권고: "
|
||||
f"{council_decision.recommended_model}"
|
||||
)
|
||||
# 회의 결정이 기존 결정과 다르고 신뢰도 높으면 우선 적용
|
||||
if council_decision.confidence >= 0.75:
|
||||
council_final = council_decision.final_decision.upper()
|
||||
if council_final != decision:
|
||||
print(
|
||||
f" 🔄 [Council Override] {decision} → {council_final} "
|
||||
f"(conf={council_decision.confidence:.2f})"
|
||||
)
|
||||
decision = council_final
|
||||
decision_reason = (
|
||||
f"AI Council ({council_decision.confidence:.0%}): "
|
||||
f"{council_decision.majority_reasoning[:80]}"
|
||||
)
|
||||
# BUY로 전환된 경우 수량 재계산
|
||||
if decision == "BUY" and suggested_qty == 0:
|
||||
suggested_qty = calculate_position_size(
|
||||
total_capital=_capital,
|
||||
current_price=current_price,
|
||||
volatility=volatility,
|
||||
score=council_decision.confidence,
|
||||
ai_confidence=ai_confidence,
|
||||
ticker=ticker,
|
||||
)
|
||||
except Exception as _ce:
|
||||
print(f" [Council] 회의 오류: {_ce}")
|
||||
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"score": total_score,
|
||||
"tech": tech_score,
|
||||
"sentiment": sentiment_score,
|
||||
"lstm_score": lstm_score,
|
||||
"investor_score": investor_score,
|
||||
"volatility": volatility,
|
||||
"volume_ratio": vol_ratio,
|
||||
"prediction": pred_result,
|
||||
"decision": decision,
|
||||
"decision_reason": decision_reason,
|
||||
"current_price": current_price,
|
||||
"ma_info": ma_info,
|
||||
"sl_tp": sl_tp,
|
||||
"suggested_qty": suggested_qty,
|
||||
"ai_confidence": ai_confidence,
|
||||
"ai_reason": ai_reason,
|
||||
"regime": {
|
||||
"kospi_level": kospi_price,
|
||||
"regime": regime_analysis.regime.value if regime_analysis else "unknown",
|
||||
"description": regime_analysis.description if regime_analysis else "",
|
||||
"risk_level": regime_analysis.risk_level if regime_analysis else "LOW",
|
||||
"model_recommendation": (
|
||||
regime_analysis.model_recommendation if regime_analysis else ""
|
||||
),
|
||||
} if regime_analysis else None,
|
||||
"council": {
|
||||
"final": council_decision.final_decision,
|
||||
"confidence": council_decision.confidence,
|
||||
"model_health": council_decision.model_health_score,
|
||||
"replace_recommended": council_decision.model_replacement_recommended,
|
||||
"recommended_model": council_decision.recommended_model,
|
||||
"summary": council_decision.council_summary,
|
||||
} if council_decision else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Worker Error] Failed to analyze {ticker}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"score": 0.0,
|
||||
"decision": "HOLD",
|
||||
"decision_reason": f"Error: {str(e)}",
|
||||
"current_price": 0,
|
||||
"sl_tp": {'stop_loss_pct': -5.0, 'take_profit_pct': 8.0, 'trailing_stop_pct': 3.0},
|
||||
"suggested_qty": 0,
|
||||
"error": str(e)
|
||||
}
|
||||
150
signal_v1/modules/strategy/risk_gate.py
Normal file
150
signal_v1/modules/strategy/risk_gate.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
포트폴리오 리스크 게이트 (v3.2)
|
||||
|
||||
매수 체결 직전 호출되어 포트폴리오 레벨 제약을 검증:
|
||||
1. 총 보유 종목 수 상한
|
||||
2. 테마당 동시 보유 종목 수 상한
|
||||
3. 테마당 노출 금액 비율 상한 (총자산 대비)
|
||||
|
||||
기존 매수 필터(예수금, 종목당 상한, 사이클당 매수 수)는 유지하고
|
||||
이 게이트가 "같은 테마에 집중되는 포지션"을 차단한다.
|
||||
|
||||
순수 함수로 구현 — 의존성 없음 → 단위 테스트 가능.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskDecision:
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
max_allowed_amount: int = 0 # 일부만 허용되는 경우 (테마 노출 상한)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskConfig:
|
||||
max_total_holdings: int = 7
|
||||
max_tickers_per_theme: int = 2
|
||||
max_theme_exposure_ratio: float = 0.40
|
||||
|
||||
|
||||
class PortfolioRiskGate:
|
||||
"""
|
||||
사용 예:
|
||||
gate = PortfolioRiskGate(theme_map, RiskConfig())
|
||||
decision = gate.evaluate_buy(
|
||||
ticker="005930",
|
||||
candidate_amount=3_000_000,
|
||||
current_holdings=[{"ticker":"000660","eval_amount":2_500_000}, ...],
|
||||
total_capital=50_000_000,
|
||||
)
|
||||
if not decision.allowed: skip
|
||||
elif decision.max_allowed_amount < candidate_amount: partial buy
|
||||
"""
|
||||
|
||||
def __init__(self, theme_lookup, config: Optional[RiskConfig] = None):
|
||||
"""
|
||||
Args:
|
||||
theme_lookup: callable(ticker:str) -> list[str] (종목→테마 매핑 함수)
|
||||
혹은 dict 형태도 허용.
|
||||
config: RiskConfig
|
||||
"""
|
||||
if callable(theme_lookup):
|
||||
self._theme_of = theme_lookup
|
||||
elif isinstance(theme_lookup, dict):
|
||||
self._theme_of = lambda t: theme_lookup.get(t, [])
|
||||
else:
|
||||
raise TypeError("theme_lookup must be callable or dict")
|
||||
self.config = config or RiskConfig()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 내부: 테마별 현재 노출 집계
|
||||
# ──────────────────────────────────────────────
|
||||
def _aggregate_by_theme(self, holdings: Iterable[dict]) -> Dict[str, dict]:
|
||||
"""
|
||||
Returns:
|
||||
{theme: {"tickers": set, "amount": int}}
|
||||
"""
|
||||
agg: Dict[str, dict] = {}
|
||||
for h in holdings:
|
||||
tkr = h.get("ticker")
|
||||
amt = int(h.get("eval_amount", 0) or 0)
|
||||
if not tkr:
|
||||
continue
|
||||
themes = self._theme_of(tkr) or []
|
||||
for th in themes:
|
||||
bucket = agg.setdefault(th, {"tickers": set(), "amount": 0})
|
||||
bucket["tickers"].add(tkr)
|
||||
bucket["amount"] += amt
|
||||
return agg
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 공개 API
|
||||
# ──────────────────────────────────────────────
|
||||
def evaluate_buy(self, ticker: str, candidate_amount: int,
|
||||
current_holdings: List[dict],
|
||||
total_capital: int) -> RiskDecision:
|
||||
"""
|
||||
매수 허가 여부 판단.
|
||||
|
||||
Returns:
|
||||
RiskDecision
|
||||
- allowed=False: 이유와 함께 차단
|
||||
- allowed=True : max_allowed_amount만큼 허용 (candidate_amount 이하)
|
||||
"""
|
||||
if candidate_amount <= 0 or total_capital <= 0:
|
||||
return RiskDecision(False, "invalid_amount")
|
||||
|
||||
cfg = self.config
|
||||
|
||||
# 이미 보유 중이면 추가 매수는 이 게이트 대상 아님 (scale-in은 상위에서 처리)
|
||||
held_tickers = {h.get("ticker") for h in current_holdings}
|
||||
is_new_position = ticker not in held_tickers
|
||||
|
||||
# 1. 총 보유 종목 수 상한
|
||||
if is_new_position and len(held_tickers) >= cfg.max_total_holdings:
|
||||
return RiskDecision(
|
||||
False,
|
||||
f"max_total_holdings: {len(held_tickers)}/{cfg.max_total_holdings}"
|
||||
)
|
||||
|
||||
themes = self._theme_of(ticker) or []
|
||||
if not themes:
|
||||
# 테마 정보 없음 → 테마 제약은 건너뛰고 통과
|
||||
return RiskDecision(True, "no_theme_info", candidate_amount)
|
||||
|
||||
by_theme = self._aggregate_by_theme(current_holdings)
|
||||
|
||||
allowed_amount = candidate_amount
|
||||
blocking_reasons = []
|
||||
|
||||
for th in themes:
|
||||
bucket = by_theme.get(th, {"tickers": set(), "amount": 0})
|
||||
|
||||
# 2. 테마당 종목 수 상한 (신규 포지션일 때만)
|
||||
if is_new_position and len(bucket["tickers"]) >= cfg.max_tickers_per_theme:
|
||||
blocking_reasons.append(
|
||||
f"theme[{th}] tickers {len(bucket['tickers'])}/{cfg.max_tickers_per_theme}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 3. 테마당 노출 금액 비율 상한
|
||||
max_theme_amount = int(total_capital * cfg.max_theme_exposure_ratio)
|
||||
remaining = max_theme_amount - bucket["amount"]
|
||||
if remaining <= 0:
|
||||
blocking_reasons.append(
|
||||
f"theme[{th}] exposure {bucket['amount']:,}/{max_theme_amount:,}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 테마 잔여액이 candidate보다 작으면 부분 허용
|
||||
allowed_amount = min(allowed_amount, remaining)
|
||||
|
||||
if blocking_reasons:
|
||||
return RiskDecision(False, "; ".join(blocking_reasons))
|
||||
|
||||
if allowed_amount <= 0:
|
||||
return RiskDecision(False, "theme_exposure_full")
|
||||
|
||||
return RiskDecision(True, "ok", allowed_amount)
|
||||
Reference in New Issue
Block a user