커밋 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 (일회성)
This commit is contained in:
Your Name
2026-08-18 00:13:17 +09:00
parent 36a3e2b4a1
commit 6d2a706a48
1416 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,307 @@
"""
변동성 기반 리스크 관리 모듈
- 예수금 비율 기반 비중 조절
- 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