수정사항

This commit is contained in:
2026-02-22 23:13:45 +09:00
parent e794267b96
commit d49c6209cb
3 changed files with 66 additions and 29 deletions

View File

@@ -19,6 +19,7 @@ from app.core.market_schedule import is_market_open
from app.services.mean_reversion_strategy import ( from app.services.mean_reversion_strategy import (
MarketRegime, detect_market_regime, check_buy_signal, MarketRegime, detect_market_regime, check_buy_signal,
calculate_atr, calculate_adx, calculate_rsi, calculate_bollinger, is_reversal_candle, calculate_atr, calculate_adx, calculate_rsi, calculate_bollinger, is_reversal_candle,
calculate_position_size_v2,
) )
from app.services.mdd_manager import mdd_manager from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker from app.services.loss_tracker import loss_tracker
@@ -1319,6 +1320,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0, "change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume, "volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None, "rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None, "ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None, "ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None, "stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
@@ -1332,10 +1334,8 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"rule_name": rule.name, "rule_name": rule.name,
"rule_description": rule.description, "rule_description": rule.description,
"rule_priority": rule.priority, "rule_priority": rule.priority,
"position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.10),
"take_profit": actions.get("take_profit", 3.0), "take_profit": actions.get("take_profit", 3.0),
"stop_loss": actions.get("stop_loss", -1.5), "stop_loss": actions.get("stop_loss", -1.5),
"max_position_pct": actions.get("max_position_pct", 0.12),
"max_add_buys": actions.get("max_add_buys", 3), "max_add_buys": actions.get("max_add_buys", 3),
"add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15), "add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15),
} }
@@ -1382,6 +1382,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0, "change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume, "volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None, "rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None, "ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None, "ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None, "stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
@@ -1395,10 +1396,8 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"rule_name": "고점수 분석 종목", "rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목", "rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99, "rule_priority": 99,
"position_ratio": 0.10,
"take_profit": 3.0, "take_profit": 3.0,
"stop_loss": -1.5, "stop_loss": -1.5,
"max_position_pct": 0.10,
"max_add_buys": 2, "max_add_buys": 2,
"add_buy_cooldown_min": 30, "add_buy_cooldown_min": 30,
} }
@@ -1900,8 +1899,9 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
# continue # continue
try: try:
# 🐻 하락장 포지션 축소 비율 전달 # 시장 레짐 정보 전달 (포지션 사이징에서 regime 복원에 사용)
stock["_position_multiplier"] = position_multiplier stock["_position_multiplier"] = position_multiplier
stock["_regime_str"] = market_regime.get("regime_200d", "bear") if market_regime else "bear"
result = await execute_auto_trade(stock, db) result = await execute_auto_trade(stock, db)
total_processed += 1 total_processed += 1
@@ -2174,10 +2174,7 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
logger.info(f"🚫 일일 매매 한도 초과: {stock_name} 당일 {daily_buy_count}/{max_daily_trades}회") logger.info(f"🚫 일일 매매 한도 초과: {stock_name} 당일 {daily_buy_count}/{max_daily_trades}회")
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
# 기본 파라미터 (권장 안전 프로파일, 규칙에서 오버라이드 가능) cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) # 추가매수 쿨다운(분)
target_ratio = float(stock.get("position_ratio", 0.10) or 0.10) # 목표 비중(자산 대비)
max_position_pct = float(stock.get("max_position_pct", 0.12) or 0.12) # 최대 비중 캡
cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) # 추가매수 쿨다운(분) - 30분으로 증가
# 🔥 쿨다운 체크 (보유 여부와 관계없이 항상 체크) # 🔥 쿨다운 체크 (보유 여부와 관계없이 항상 체크)
# 포트폴리오 동기화가 느릴 수 있으므로, DB 거래 내역 기준으로 체크 # 포트폴리오 동기화가 느릴 수 있으므로, DB 거래 내역 기준으로 체크
@@ -2220,23 +2217,21 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
logger.info(f"🚫 MCP AI 매수 거부: {stock_name} ({mcp_reason})") logger.info(f"🚫 MCP AI 매수 거부: {stock_name} ({mcp_reason})")
return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason} return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason}
if total_assets > 0: # 동적 포지션 사이징 (CLAUDE.md 섹션 6.1: 종목당 최대 4%, regime/RSI/ADX 반영)
current_value = (holding.quantity * current_price) if holding else 0.0 current_value = float(holding.quantity * current_price) if holding else 0.0
desired_value = total_assets * target_ratio regime_str = stock.get("_regime_str", "bear")
max_allowed_value = total_assets * max_position_pct regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
add_value = max(0.0, desired_value - current_value) invest_amount = calculate_position_size_v2(
add_value = min(add_value, max(0.0, max_allowed_value - current_value)) total_assets=total_assets,
invest_amount = min(add_value, available_cash) available_cash=available_cash,
else: current_strategy_allocation=current_value,
# 총자산 미확정 시 보수적으로 현금의 10% regime=regime,
invest_amount = available_cash * 0.10 rsi=stock.get("rsi"),
adx=stock.get("adx_14"),
# 🐻 하락장 포지션 축소 적용 )
pos_mult = float(stock.get("_position_multiplier", 1.0)) if invest_amount <= 0 and total_assets <= 0:
if pos_mult < 1.0: # 총자산 미확정 시 보수적으로 현금의 4%
original_amount = invest_amount invest_amount = available_cash * 0.04
invest_amount = invest_amount * pos_mult
logger.info(f"🐻 하락장 포지션 축소: {stock_name} {original_amount:,.0f}원 → {invest_amount:,.0f}원 (x{pos_mult})")
quantity = int(invest_amount / current_price) quantity = int(invest_amount / current_price)

View File

@@ -381,6 +381,46 @@ def calculate_position_size(
return result return result
def calculate_position_size_v2(
total_assets: float,
available_cash: float,
current_strategy_allocation: float,
regime: MarketRegime,
rsi: Optional[float] = None,
adx: Optional[float] = None,
max_per_position: float = 0.04,
max_strategy_capital_bull: float = 0.70,
max_strategy_capital_bear: float = 0.30,
) -> float:
"""CLAUDE.md 섹션 6.1 + RSI/ADX 동적 포지션 사이징
calculate_position_size 기반으로 RSI·ADX 보정 추가:
- RSI ≤ 20: ×1.2 (깊은 과매도 = 더 유리한 진입)
- RSI ≤ 25: ×1.1
- ADX > 30: ×0.8 (강추세 중 평균회귀 위험 = 포지션 축소)
보정 후에도 종목당 4% 상한 재적용 (CLAUDE.md 절대 룰)
"""
base = calculate_position_size(
total_assets, available_cash, current_strategy_allocation,
regime, max_per_position, max_strategy_capital_bull, max_strategy_capital_bear,
)
if base <= 0:
return 0.0
mult = 1.0
if rsi is not None:
if rsi <= 20:
mult *= 1.2
elif rsi <= 25:
mult *= 1.1
if adx is not None and adx > 30:
mult *= 0.8
max_allowed = total_assets * max_per_position
return min(base * mult, max_allowed, available_cash)
def check_h1_breakout_with_ma5( def check_h1_breakout_with_ma5(
h1_closes: List[float], h1_closes: List[float],
h1_current_close: float, h1_current_close: float,

View File

@@ -73,6 +73,7 @@ async def initialize_watchlist(
top_codes = {row.stock_code for row in top_stocks} top_codes = {row.stock_code for row in top_stocks}
added = 0 added = 0
scanner_added = 0
added_codes: set[str] = set() added_codes: set[str] = set()
scanner_slots = SCANNER_PRIORITY_SLOTS scanner_slots = SCANNER_PRIORITY_SLOTS
@@ -90,6 +91,7 @@ async def initialize_watchlist(
added_codes.add(row.stock_code) added_codes.add(row.stock_code)
scanner_slots -= 1 scanner_slots -= 1
added += 1 added += 1
scanner_added += 1
# --- 2순위: 스캐너에만 있고 score 랭킹에는 없는 종목 (최대 scanner_slots개) --- # --- 2순위: 스캐너에만 있고 score 랭킹에는 없는 종목 (최대 scanner_slots개) ---
extra = list(scanner_tickers - top_codes) extra = list(scanner_tickers - top_codes)
@@ -113,6 +115,7 @@ async def initialize_watchlist(
)) ))
added_codes.add(row.stock_code) added_codes.add(row.stock_code)
added += 1 added += 1
scanner_added += 1
# --- 3순위: 나머지 슬롯 — score 상위 순 --- # --- 3순위: 나머지 슬롯 — score 상위 순 ---
remaining = WATCHLIST_INIT_SIZE - added remaining = WATCHLIST_INIT_SIZE - added
@@ -132,12 +135,11 @@ async def initialize_watchlist(
await session.commit() await session.commit()
scanner_added = added - remaining if remaining >= 0 else added
logger.info( logger.info(
"📋 워치리스트 초기화: %d개 (scanner_signal=%d, morning_batch=%d, 기준일: %s)", "📋 워치리스트 초기화: %d개 (scanner_signal=%d, morning_batch=%d, 기준일: %s)",
added, added,
len([c for c in added_codes]), # 로깅용 (simplified) scanner_added,
remaining if remaining >= 0 else 0, added - scanner_added,
analysis_date, analysis_date,
) )
return added return added