From d49c6209cb25bf9faec3abc6af37462da5549297 Mon Sep 17 00:00:00 2001 From: shuim Date: Sun, 22 Feb 2026 23:13:45 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/endpoints/recommendations.py | 47 +++++++++---------- .../app/services/mean_reversion_strategy.py | 40 ++++++++++++++++ backend/app/services/watchlist.py | 8 ++-- 3 files changed, 66 insertions(+), 29 deletions(-) diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 7967a08..88297d2 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -19,6 +19,7 @@ from app.core.market_schedule import is_market_open from app.services.mean_reversion_strategy import ( MarketRegime, detect_market_regime, check_buy_signal, 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.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, "volume": stock.volume, "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, "ma20": int(stock.ma20) if stock.ma20 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_description": rule.description, "rule_priority": rule.priority, - "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.10), "take_profit": actions.get("take_profit", 3.0), "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), "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, "volume": stock.volume, "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, "ma20": int(stock.ma20) if stock.ma20 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_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목", "rule_priority": 99, - "position_ratio": 0.10, "take_profit": 3.0, "stop_loss": -1.5, - "max_position_pct": 0.10, "max_add_buys": 2, "add_buy_cooldown_min": 30, } @@ -1900,8 +1899,9 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn # continue try: - # 🐻 하락장 포지션 축소 비율 전달 + # 시장 레짐 정보 전달 (포지션 사이징에서 regime 복원에 사용) 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) 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}회") return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} - # 기본 파라미터 (권장 안전 프로파일, 규칙에서 오버라이드 가능) - 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분으로 증가 + cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) # 추가매수 쿨다운(분) # 🔥 쿨다운 체크 (보유 여부와 관계없이 항상 체크) # 포트폴리오 동기화가 느릴 수 있으므로, DB 거래 내역 기준으로 체크 @@ -2220,23 +2217,21 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): logger.info(f"🚫 MCP AI 매수 거부: {stock_name} ({mcp_reason})") return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason} - if total_assets > 0: - current_value = (holding.quantity * current_price) if holding else 0.0 - desired_value = total_assets * target_ratio - max_allowed_value = total_assets * max_position_pct - add_value = max(0.0, desired_value - current_value) - add_value = min(add_value, max(0.0, max_allowed_value - current_value)) - invest_amount = min(add_value, available_cash) - else: - # 총자산 미확정 시 보수적으로 현금의 10% - invest_amount = available_cash * 0.10 - - # 🐻 하락장 포지션 축소 적용 - pos_mult = float(stock.get("_position_multiplier", 1.0)) - if pos_mult < 1.0: - original_amount = invest_amount - invest_amount = invest_amount * pos_mult - logger.info(f"🐻 하락장 포지션 축소: {stock_name} {original_amount:,.0f}원 → {invest_amount:,.0f}원 (x{pos_mult})") + # 동적 포지션 사이징 (CLAUDE.md 섹션 6.1: 종목당 최대 4%, regime/RSI/ADX 반영) + current_value = float(holding.quantity * current_price) if holding else 0.0 + regime_str = stock.get("_regime_str", "bear") + regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR + invest_amount = calculate_position_size_v2( + total_assets=total_assets, + available_cash=available_cash, + current_strategy_allocation=current_value, + regime=regime, + rsi=stock.get("rsi"), + adx=stock.get("adx_14"), + ) + if invest_amount <= 0 and total_assets <= 0: + # 총자산 미확정 시 보수적으로 현금의 4% + invest_amount = available_cash * 0.04 quantity = int(invest_amount / current_price) diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index c1db101..5b75c02 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -381,6 +381,46 @@ def calculate_position_size( 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( h1_closes: List[float], h1_current_close: float, diff --git a/backend/app/services/watchlist.py b/backend/app/services/watchlist.py index 1fbbfff..ba9df38 100644 --- a/backend/app/services/watchlist.py +++ b/backend/app/services/watchlist.py @@ -73,6 +73,7 @@ async def initialize_watchlist( top_codes = {row.stock_code for row in top_stocks} added = 0 + scanner_added = 0 added_codes: set[str] = set() scanner_slots = SCANNER_PRIORITY_SLOTS @@ -90,6 +91,7 @@ async def initialize_watchlist( added_codes.add(row.stock_code) scanner_slots -= 1 added += 1 + scanner_added += 1 # --- 2순위: 스캐너에만 있고 score 랭킹에는 없는 종목 (최대 scanner_slots개) --- extra = list(scanner_tickers - top_codes) @@ -113,6 +115,7 @@ async def initialize_watchlist( )) added_codes.add(row.stock_code) added += 1 + scanner_added += 1 # --- 3순위: 나머지 슬롯 — score 상위 순 --- remaining = WATCHLIST_INIT_SIZE - added @@ -132,12 +135,11 @@ async def initialize_watchlist( await session.commit() - scanner_added = added - remaining if remaining >= 0 else added logger.info( "📋 워치리스트 초기화: %d개 (scanner_signal=%d, morning_batch=%d, 기준일: %s)", added, - len([c for c in added_codes]), # 로깅용 (simplified) - remaining if remaining >= 0 else 0, + scanner_added, + added - scanner_added, analysis_date, ) return added