Files
kis_bot/remove/legacy_root/risk_manager.py
Your Name 6d2a706a48 커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병.

넣을 파일

신규: kis_trader/engine/live_sell_price.py
kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price)
전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py
WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py
kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0)
database.py (키 주석 + legacy/ sys.path)
kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고)
EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후)
문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구)
메시지 초안

fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM)
횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다.
호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백.
영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님)

커밋 2 — 루트 정리 (remove/ vs legacy/)
왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐.

넣을 파일

이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등)
이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2)
신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md
import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py
docs/like_mcp.md/code_architecture.md 수동 노트
메시지 초안

chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/
remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은
ensure_legacy_root로 legacy/만 본다.
빼기: scratch/set_ws_price_max_age_zero.py (일회성)
2026-08-18 00:13:17 +09:00

308 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
변동성 기반 리스크 관리 모듈
- 예수금 비율 기반 비중 조절
- ATR/변동성 역가중 (Volatility Inverse Weighting)
- 종목별 안전 매수 금액 계산
"""
import logging
import pandas as pd
import numpy as np
from typing import List, Dict
logger = logging.getLogger("RiskManager")
class RiskManager:
"""
[예수금 연동 리스크 관리 모듈]
- 고정 금액이 아닌 '현재 예수금' 대비 퍼센트로 투자금 계산
- 변동성 큰 종목 = 적게 매수, 변동성 작은 종목 = 많이 매수
"""
def __init__(
self,
risk_pct_per_trade: float = 0.02, # 1회 거래 시 허용 손실 비율 (2%)
max_position_pct: float = 0.20, # 종목당 최대 비중 (20%)
min_position_amount: int = 50000, # 최소 매수 금액 (5만원)
use_kelly: bool = False, # 켈리 공식 사용 여부
kelly_multiplier: float = 0.5, # 켈리 배율 (0.5 = 하프 켈리)
slot_base_amount_cap: int = 0, # 종목당 원화 상한 (0=미적용)
# ── 반드시 적용되는 MAX_LOSS 기반 절대 상한 ─────────────────────
# max_loss_per_trade_krw / |stop_loss_pct| = 투자 상한 금액
# 0이면 미적용 (RiskManager 계산 결과를 그대로 사용)
max_loss_per_trade_krw: int = 0, # 1회 최대 손실 허용액(원), MAX_LOSS_PER_TRADE_KRW
stop_loss_pct: float = 0.0, # 손절 비율(음수 가능), STOP_LOSS_PCT
# ── 사이즈 클래스별 매수 비율 (env/DB에서 주입, 하드코딩 금지) ──
size_small_ratio: float = 0.70, # 소형주 투자 비율 (SIZE_CLASS_SMALL_RATIO)
size_mid_ratio: float = 0.85, # 중형주 투자 비율 (SIZE_CLASS_MID_RATIO)
):
"""
Args:
risk_pct_per_trade: 1회 매매 시 허용할 손실 비율 (기본 0.02 = 2%)
max_position_pct: 종목당 최대 투자 비율 (기본 0.20 = 20%)
min_position_amount: 최소 매수 금액 (너무 작은 주문 방지)
use_kelly: 켈리 공식 활성화 여부
kelly_multiplier: 켈리 배율 (0.5 = 하프켈리, 0.25 = 쿼터켈리)
slot_base_amount_cap: 종목당 매수 금액 원화 상한 (0이면 미적용, SLOT_BASE_AMOUNT_CAP)
"""
self.risk_pct = risk_pct_per_trade
self.max_pos_pct = max_position_pct
self.min_amount = min_position_amount
self.use_kelly = use_kelly
self.kelly_mult = kelly_multiplier
self.slot_base_amount_cap = slot_base_amount_cap if slot_base_amount_cap and slot_base_amount_cap > 0 else 0
# ── MAX_LOSS 기반 절대 투자 상한 ─────────────────────────────────
# = MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT|
# 0이면 미적용 (stop_loss_pct 가 0이면 계산 불가이므로 미적용)
self._max_loss_krw = max_loss_per_trade_krw
_stop_abs = abs(stop_loss_pct)
self._max_loss_cap = int(max_loss_per_trade_krw / _stop_abs) if (max_loss_per_trade_krw > 0 and _stop_abs > 0) else 0
# ── 사이즈 클래스 비율 (하드코딩 금지) ────────────────────────────
self.size_small_ratio = size_small_ratio # 소형주 (SIZE_CLASS_SMALL_RATIO)
self.size_mid_ratio = size_mid_ratio # 중형주 (SIZE_CLASS_MID_RATIO)
logger.info(
f"💰 RiskManager 초기화: 리스크{self.risk_pct*100}% | "
f"최대비중{self.max_pos_pct*100}% | 켈리{'ON' if use_kelly else 'OFF'}"
+ (f" | 슬롯상한{self.slot_base_amount_cap:,}" if self.slot_base_amount_cap else "")
+ (f" | MAX_LOSS상한{self._max_loss_cap:,}" if self._max_loss_cap else "")
)
def calculate_volatility_atr(self, df: pd.DataFrame, period: int = 14) -> float:
"""
ATR(Average True Range) 기반 변동성 계산
Args:
df: OHLC 데이터프레임 (컬럼: open, high, low, close)
period: ATR 계산 기간 (기본 14)
Returns:
현재가 대비 ATR 비율 (%)
"""
try:
if df is None or len(df) < period:
logger.warning(f"⚠️ ATR 계산: 데이터 부족 -> 기본값 3.0% 리턴")
return 3.0
# True Range 계산
df = df.copy()
df['tr'] = np.maximum(
df['high'] - df['low'],
np.maximum(
np.abs(df['high'] - df['close'].shift()),
np.abs(df['low'] - df['close'].shift())
)
)
# ATR = TR의 이동평균
atr = df['tr'].rolling(window=period).mean().iloc[-1]
current_price = df['close'].iloc[-1]
if current_price == 0:
return 3.0
# 현재가 대비 ATR 비율 (%)
volatility_pct = (atr / current_price) * 100
# 최소값 보정 (0.5% 미만은 0.5%로)
return max(round(volatility_pct, 2), 0.5)
except Exception as e:
logger.error(f"❌ ATR 계산 에러: {e}")
return 3.0
def calculate_volatility_simple(self, candles: List[Dict], period: int = 10) -> float:
"""
간단한 변동성 계산 (고저차 비율 평균)
Args:
candles: 캔들 데이터 리스트 [{'high': , 'low': , 'close': }, ...]
period: 계산 기간 (기본 10일)
Returns:
변동성 (%)
"""
try:
if not candles or len(candles) < period:
return 3.0
recent = candles[-period:]
volatility_sum = 0.0
for candle in recent:
high = float(candle.get('high', 0))
low = float(candle.get('low', 0))
close = float(candle.get('close', 1))
if close == 0:
continue
# (고가 - 저가) / 종가
volatility_sum += (high - low) / close
avg_vol = (volatility_sum / len(recent)) * 100
return max(round(avg_vol, 2), 0.5)
except Exception as e:
logger.error(f"❌ 변동성 계산 에러: {e}")
return 3.0
def get_position_size(
self,
stock_name: str,
current_balance: float,
volatility_pct: float = None,
df: pd.DataFrame = None,
kelly_fraction: float = None,
size_class: str = None,
) -> int:
"""
[메인 함수] 종목별 안전 매수 금액 계산
Args:
stock_name: 종목명 (로깅용)
current_balance: 현재 예수금
volatility_pct: 변동성 (직접 제공 시)
df: OHLC 데이터프레임 (ATR 계산용)
kelly_fraction: 켈리 비율 (DB에서 계산한 값)
size_class: 대/중/소형 구분 ("", "", "")
Returns:
추천 매수 금액 (원)
"""
try:
if current_balance <= 0:
logger.warning(f"⚠️ [{stock_name}] 예수금 0원 이하")
return 0
# 1. 변동성 계산
if volatility_pct is None:
if df is not None and not df.empty:
volatility_pct = self.calculate_volatility_atr(df)
else:
logger.warning(f"⚠️ [{stock_name}] 변동성 데이터 없음 -> 기본값 3.0%")
volatility_pct = 3.0
# 2. 기본 리스크 금액 계산 (예수금 * 리스크 비율)
# 예: 1,000만 원 * 2% = 20만 원 (이 종목에서 최대 20만원까지 잃을 수 있음)
allowable_risk = current_balance * self.risk_pct
# 3. 켈리 공식 적용 (쿼터 켈리/하프 켈리)
if self.use_kelly:
if kelly_fraction is not None and kelly_fraction > 0:
# DB에서 계산한 켈리 비율 사용 (하프 켈리)
base_position_pct = kelly_fraction * self.kelly_mult
allowable_risk = current_balance * base_position_pct
logger.info(f"🎲 [{stock_name}] 켈리 적용(DB): {base_position_pct*100:.1f}% (켈리={kelly_fraction*100:.1f}% × 배율={self.kelly_mult})")
else:
# 켈리 비율이 없으면 쿼터 켈리 배율을 기본값으로 사용
# 예: 리스크 1% × 쿼터 켈리 0.25 = 0.25% (더 보수적)
base_position_pct = self.risk_pct * self.kelly_mult
allowable_risk = current_balance * base_position_pct
logger.info(f"🎲 [{stock_name}] 켈리 적용(기본): {base_position_pct*100:.1f}% (리스크={self.risk_pct*100}% × 쿼터켈리={self.kelly_mult})")
# 4. 변동성 역가중으로 매수 금액 계산
# 공식: (허용 손실액) / (변동성 비율)
# 변동성 5% -> 20만 / 0.05 = 400만원 매수
# 변동성 10% -> 20만 / 0.10 = 200만원 매수
risk_ratio = volatility_pct / 100.0
calculated_amount = allowable_risk / risk_ratio
# 5. 상한선 체크 (종목당 최대 비중)
max_limit = current_balance * self.max_pos_pct
if calculated_amount > max_limit:
final_amount = max_limit
note = f"(최대{self.max_pos_pct*100:.0f}% 제한)"
else:
final_amount = calculated_amount
note = "(변동성기반)"
# 5-2. 대형/소형 구간별 조정 (소형주는 포지션 축소)
# → 비율은 하드코딩 금지: __init__ 시 SIZE_CLASS_SMALL/MID_RATIO 주입
if size_class == "":
final_amount = int(final_amount * self.size_small_ratio)
note = f"(소형주 {self.size_small_ratio*100:.0f}%)"
elif size_class == "":
final_amount = int(final_amount * self.size_mid_ratio)
note = f"(중형주 {self.size_mid_ratio*100:.0f}%)"
elif size_class == "":
pass # 대형주는 기존과 동일
# 5-3. 슬롯 원화 상한 (SLOT_BASE_AMOUNT_CAP, 0이면 미적용)
if self.slot_base_amount_cap > 0 and final_amount > self.slot_base_amount_cap:
final_amount = self.slot_base_amount_cap
note = f"(슬롯상한{self.slot_base_amount_cap:,}원)"
# ── 5-4. MAX_LOSS 기반 절대 투자 상한 ────────────────────────
# 어떤 전략이든 MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT| 를 초과 불가
# 이 값을 넘으면 손절 시 의도한 금액 손실(원)을 초과하게 됨
if self._max_loss_cap > 0 and final_amount > self._max_loss_cap:
final_amount = self._max_loss_cap
note = f"(MAX_LOSS상한 {self._max_loss_cap:,}원)"
# 6. 하한선 체크 (너무 작은 금액은 거래 안함)
if final_amount < self.min_amount:
logger.info(
f"🚫 [{stock_name}] 계산 금액({final_amount:,.0f}원) < "
f"최소금액({self.min_amount:,.0f}원) -> 매수 보류"
)
return 0
logger.info(
f"💰 [{stock_name}] 예수금:{current_balance:,.0f}원 | "
f"변동성:{volatility_pct:.2f}% | 추천:{int(final_amount):,.0f}{note}"
)
return int(final_amount)
except Exception as e:
logger.error(f"❌ [{stock_name}] 포지션 사이즈 계산 실패: {e}")
return 0
def calculate_quantity(
self,
current_price: float,
target_amount: int,
fee_rate: float = 0.00015
) -> int:
"""
목표 금액을 현재가와 수수료 고려하여 수량으로 변환
Args:
current_price: 현재 주가
target_amount: 목표 투자 금액
fee_rate: 수수료율 (기본 0.015%)
Returns:
매수 가능 수량 (주)
"""
try:
if current_price <= 0 or target_amount <= 0:
return 0
# 수수료 고려 실제 매수 가능 금액
# 총비용 = 가격 * 수량 * (1 + 수수료율)
# 수량 = 목표금액 / (가격 * (1 + 수수료율))
max_buy_amount = target_amount / (1 + fee_rate)
quantity = int(max_buy_amount / current_price)
if quantity < 1:
return 0
# 예상 총 비용 계산 (확인용)
expected_cost = quantity * current_price * (1 + fee_rate)
logger.debug(
f"💵 수량 계산: {current_price:,.0f}× {quantity}주 = "
f"예상비용 {expected_cost:,.0f}원 (목표: {target_amount:,.0f}원)"
)
return quantity
except Exception as e:
logger.error(f"❌ 수량 계산 에러: {e}")
return 0