diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6b34a99..d0dd6d1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -19,7 +19,11 @@ "Bash(python -c:*)", "Bash(git pull:*)", "Bash(git stash:*)", - "Bash(git -C /Users/commylife/myproject/java/workspace/auto-stock ls-files)" + "Bash(git -C /Users/commylife/myproject/java/workspace/auto-stock ls-files)", + "Bash(docker-compose ps:*)", + "Bash(docker build:*)", + "mcp__auto-stock__get_trade_history", + "mcp__auto-stock__get_portfolio" ] }, "enableAllProjectMcpServers": true, diff --git a/.gitignore b/.gitignore index 8b642e5..cd1dfec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +# PostgreSQL 백업 파일 (용량 큼, git 추적 불필요) +backups/*.sql.gz +backups/*.dump + # Environment .env .env.local diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 16640b4..d72c370 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -524,6 +524,35 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): "bollinger_middle": float(analysis_record.bollinger_middle) if analysis_record.bollinger_middle else None, "bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None, } + else: + # DB에 분석 데이터 없으면 KIS 일봉 차트로 직접 계산 (fallback) + try: + from app.services.mean_reversion_strategy import calculate_rsi, calculate_bollinger + chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=60) + chart_data = chart_resp.get("output2", []) + if len(chart_data) >= 20: + closes = [float(c.get("stck_clpr", 0)) for c in reversed(chart_data)] + highs = [float(c.get("stck_hgpr", 0)) for c in reversed(chart_data)] + lows = [float(c.get("stck_lwpr", 0)) for c in reversed(chart_data)] + rsi_val = calculate_rsi(closes) + bb = calculate_bollinger(closes) + ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None + ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else None + ma60 = sum(closes[-60:]) / 60 if len(closes) >= 60 else None + indicators = { + "rsi": round(rsi_val, 4) if rsi_val else None, + "ma5": round(ma5, 2) if ma5 else None, + "ma20": round(ma20, 2) if ma20 else None, + "ma60": round(ma60, 2) if ma60 else None, + "stochastic_k": None, + "stochastic_d": None, + "bollinger_upper": round(bb["upper"], 2) if bb else None, + "bollinger_middle": round(bb["middle"], 2) if bb else None, + "bollinger_lower": round(bb["lower"], 2) if bb else None, + } + logger.info(f"📊 {stock_code} 지표 실시간 계산 (DB 없음 fallback)") + except Exception as fe: + logger.debug(f"지표 fallback 계산 실패 ({stock_code}): {fe}") # 3. 규칙 기반 분석 로직 recommendation = "HOLD" # 기본 추천: 보유 @@ -654,7 +683,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): # 4. 종합 결과 반환 return { "stock_code": stock_code, - "stock_name": output.get("stck_prdt_name", ""), + "stock_name": output.get("hts_kor_isnm", "") or output.get("stck_prdt_name", ""), "current_price": current_price, "high_price": high_price, "low_price": low_price, @@ -663,7 +692,8 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)): "ai_analysis": analysis_text, "recommendation": recommendation, "confidence": confidence, - "trend": trend + "trend": trend, + "analyzed_at": datetime.now().isoformat(), } except HTTPException: diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index c462c47..1efc09f 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -135,12 +135,14 @@ class PortfolioCache: continue return 0.0 - # 현금: D+2 정산액 우선 -> 예수금총액 -> 순자산금액 순 + # 현금: 미수금 발생 방지를 위해 실제 현재 예수금 기준 사용 + # d2_depo_amt(D+2 정산액)는 미청산 매도 대금 포함으로 실제보다 높게 나올 수 있음 + # → dnca_tot_amt(예수금 총액, 현재 즉시 사용 가능) 우선 사용 available_cash = pick_float(output2, [ - "d2_depo_amt", # D+2 정산액 + "dnca_tot_amt", # 예수금 총액 (현재 실제 현금, 미수 방지 기준) + "d2_depo_amt", # D+2 정산액 (fallback) "d2_deposit", # 일부 응답 변형 키 - "dnca_tot_amt", # 예수금 총액 - "nass_amt" # 순자산금액 (fallback) + "nass_amt" # 순자산금액 (최후 fallback) ]) # 총자산: KIS API의 tot_evlu_amt 사용 (가장 정확) diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index cdf6881..9bd997b 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -1398,7 +1398,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): "ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None, "ml_signal": stock.ml_signal, "price_date": stock.analysis_date.strftime("%Y%m%d"), - "rule_id": 0, + "rule_id": None, "rule_name": "고점수 분석 종목", "rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목", "rule_priority": 99, @@ -1412,7 +1412,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): if fallback_stocks: recommendations_by_rule.append({ - "rule_id": 0, + "rule_id": None, "rule_name": "고점수 분석 종목", "rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목", "rule_type": "ANALYSIS", @@ -1442,6 +1442,11 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession): "recommendations": rule_matches, "recommendations_by_rule": recommendations_by_rule, "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules], + # DB에 저장된 실제 배치 분석 시각 (API 요청 시각이 아님) + "batch_time": max( + (s.analysis_date for s in all_analyzed_stocks), + default=datetime.now() + ).isoformat(), } @@ -1562,7 +1567,7 @@ async def get_rule_based_recommendations_realtime(): "signals": rec.signals, "score": rec.score, "price_date": rec.price_date, - "rule_id": 0, + "rule_id": None, "rule_name": "기본 추천", "rule_description": "RSI + 이동평균 기반 추천", "rule_priority": 1, @@ -1578,7 +1583,7 @@ async def get_rule_based_recommendations_realtime(): "analyzed_stocks": len(stocks_to_analyze), "recommendations": default_stocks, "recommendations_by_rule": [{ - "rule_id": 0, + "rule_id": None, "rule_name": "기본 추천 (RSI + 이동평균)", "rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목", "rule_type": "BUY", @@ -1591,11 +1596,29 @@ async def get_rule_based_recommendations_realtime(): } logger.info(f"Found {len(active_rules)} active trading rules") - + # 분석할 종목 리스트 (타임아웃 방지를 위해 10개로 제한) all_stocks = await get_all_tradable_stocks() stocks_to_analyze = all_stocks[:10] # 상위 10개 종목으로 제한 - + + # 보유 포트폴리오 종목 추가 (보유 종목은 거래대금 순위 무관하게 항상 분석) + try: + from app.models.models import Portfolio as _PortfolioModel + async with AsyncSessionLocal() as _pf_db: + _pf_result = await _pf_db.execute( + select(_PortfolioModel).where(_PortfolioModel.quantity > 0) + ) + _pf_stocks = _pf_result.scalars().all() + existing_codes = {code for code, _ in stocks_to_analyze} + for _pf in _pf_stocks: + if _pf.stock_code not in existing_codes: + stocks_to_analyze = list(stocks_to_analyze) + stocks_to_analyze.append((_pf.stock_code, _pf.stock_name or _pf.stock_code)) + existing_codes.add(_pf.stock_code) + logger.info(f"Added portfolio stocks → total {len(stocks_to_analyze)} stocks to analyze") + except Exception as _e: + logger.warning(f"포트폴리오 종목 추가 실패 (non-critical): {_e}") + # ✅ 최적화: 모든 종목을 한 번만 분석 (모든 지표 포함) logger.info(f"Analyzing {len(stocks_to_analyze)} stocks once with all indicators...") criteria = RecommendationCriteria( @@ -1603,7 +1626,8 @@ async def get_rule_based_recommendations_realtime(): use_moving_average=True, use_macd=True, use_bollinger=True, - use_stochastic=True + use_stochastic=True, + for_batch=True, # score=0 종목도 반환하여 모든 지표 DB 저장 ) # 배치 처리로 종목 분석 (Rate Limiting) @@ -1739,50 +1763,66 @@ async def get_rule_based_recommendations_realtime(): stock["current_price"] = updated["current_price"] stock["change_rate"] = updated["change_rate"] - # 실시간 분석 결과를 DB에 저장 + # 실시간 분석 결과를 DB에 저장 (규칙 매칭 여부와 무관하게 분석된 전 종목 저장) from app.models.models import StockAnalysis - from sqlalchemy.ext.asyncio import AsyncSession - from app.core.database import AsyncSessionLocal - import datetime - - db = AsyncSessionLocal() - today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - for rec in top_recommendations: - # 중복 저장 방지: 오늘자+종목코드 기준 - exists = await db.execute( - StockAnalysis.__table__.select().where( - StockAnalysis.stock_code == rec["stock_code"], - StockAnalysis.analysis_date == today - ) - ) - if not exists.first(): - stock_obj = StockAnalysis( - stock_code=rec["stock_code"], - stock_name=rec["stock_name"], - analysis_date=today, - current_price=rec["current_price"], - change_rate=rec.get("change_rate", 0), - volume=rec.get("volume", 0), - rsi=rec.get("rsi", None), - ma5=rec.get("ma5", None), - ma20=rec.get("ma20", None), - ma60=rec.get("ma60", None), - stochastic_k=rec.get("stochastic_k", None), - stochastic_d=rec.get("stochastic_d", None), - bollinger_upper=rec.get("bollinger_upper", None), - bollinger_lower=rec.get("bollinger_lower", None), - bollinger_middle=rec.get("bollinger_middle", None), - signals=rec.get("signals", []), - score=rec.get("score", 0), - ml_score=rec.get("ml_score"), - ml_confidence=rec.get("ml_confidence"), - ml_signal=rec.get("ml_signal"), - ) - db.add(stock_obj) - try: - await db.commit() - finally: - await db.close() + from datetime import date as _date + _today = _date.today() + _now = datetime.now() + async with AsyncSessionLocal() as _db: + # valid_analyzed_stocks = StockRecommendation Pydantic 모델 리스트 + # top_recommendations에만 저장하면 급등과열/필터로 모두 차단될 때 아무것도 저장 안 됨 + for rec in valid_analyzed_stocks: + _existing = (await _db.execute( + select(StockAnalysis).where( + StockAnalysis.stock_code == rec.stock_code, + StockAnalysis.analysis_date >= _today, + ) + )).scalar_one_or_none() + if _existing: + # 기존 레코드 업데이트 (지표 갱신) + _existing.analysis_date = _now # 배치 실행 시각으로 갱신 + _existing.current_price = rec.current_price + _existing.change_rate = rec.change_rate or 0 + _existing.volume = rec.volume or 0 + _existing.rsi = rec.rsi + _existing.ma5 = rec.ma5 + _existing.ma20 = rec.ma20 + _existing.ma60 = rec.ma60 + _existing.stochastic_k = rec.stochastic_k + _existing.stochastic_d = rec.stochastic_d + _existing.bollinger_upper = rec.bollinger_upper + _existing.bollinger_lower = rec.bollinger_lower + _existing.bollinger_middle = rec.bollinger_middle + _existing.signals = rec.signals or [] + _existing.score = rec.score + _existing.ml_score = rec.ml_score + _existing.ml_confidence = rec.ml_confidence + _existing.ml_signal = rec.ml_signal + else: + _db.add(StockAnalysis( + stock_code=rec.stock_code, + stock_name=rec.stock_name, + analysis_date=_now, + current_price=rec.current_price, + change_rate=rec.change_rate or 0, + volume=rec.volume or 0, + rsi=rec.rsi, + ma5=rec.ma5, + ma20=rec.ma20, + ma60=rec.ma60, + stochastic_k=rec.stochastic_k, + stochastic_d=rec.stochastic_d, + bollinger_upper=rec.bollinger_upper, + bollinger_lower=rec.bollinger_lower, + bollinger_middle=rec.bollinger_middle, + signals=rec.signals or [], + score=rec.score, + ml_score=rec.ml_score, + ml_confidence=rec.ml_confidence, + ml_signal=rec.ml_signal, + )) + await _db.commit() + logger.info(f"✅ [Batch] DB upsert 완료: {len(valid_analyzed_stocks)}종목 저장") return { "total": len(rule_matches), "active_rules": len(active_rules), @@ -1848,8 +1888,8 @@ async def _mcp_validate_buy(stock: dict) -> tuple: return False, f"mcp_rejected(decision={decision}, conf={confidence:.2f})" except Exception as e: - logger.warning(f"⚠️ MCP 검증 실패 (스킵하고 계속): {e}") - return True, "mcp_unavailable" # 장애 시 매매 차단 안 함 + logger.warning(f"⚠️ MCP 검증 실패 — 매매 차단: {e}") + return False, "mcp_unavailable" # 장애 시 매매 차단 (fail-closed) async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): @@ -2055,10 +2095,11 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): sell_keywords = [ "역배열 하락 매도타점", - "RSI 과매수", - "데드크로스", + "RSI 과매수", # RSI > 70 (rsi_signal 함수 출력과 일치) + "스토캐스틱 과매수", # stochastic K > 70 (stochastic_signal 함수 출력과 일치) + "데드크로스", # "MACD 데드크로스" 부분 매칭 "이동평균 역배열", - "볼린저 상한선 돌파", + "볼린저 상한선 돌파", # price > upper_band (bollinger_signal 함수 출력과 일치) "손절라인 이탈", ] should_sell = any(k in text_signals for k in sell_keywords) @@ -2085,7 +2126,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): logger.info(f"🤖 ML 관망 신호로 매수 억제: {stock_name}") # ✅ 규칙 기반 추천 종목도 안전장치 적용 (이격도 과열, 급등 체크) - has_rule = stock.get("rule_id") is not None or stock.get("rule_name") is not None + # rule_id가 있을 때만 실제 규칙 매칭으로 간주 (폴백 "고점수 분석 종목"은 제외) + has_rule = stock.get("rule_id") is not None if has_rule: # 이격도 과열 시 매수 금지 if "이격도 과열" in text_signals or "매수금지" in text_signals: @@ -2097,12 +2139,40 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): logger.info(f"🚫 규칙 추천이지만 급등/과열 금지: {stock_name}") return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} - should_buy = True - logger.info(f"📋 규칙 기반 추천: {stock_name} - {stock.get('rule_name', '매수규칙')}") + # ML이 명시적으로 HOLD를 내렸으면 규칙 기반 강제매수 금지 + # (ML 활성화 AND ML=HOLD AND 이전 단계에서 should_buy가 False로 설정됨) + ml_hold_active = ( + settings.ML_ENSEMBLE_ENABLED + and ml_signal_trade == "HOLD" + and not should_buy + ) + if ml_hold_active: + logger.info(f"🤖 ML 관망으로 규칙 기반 강제매수 차단: {stock_name}") + else: + should_buy = True + logger.info(f"📋 규칙 기반 추천: {stock_name} - {stock.get('rule_name', '매수규칙')}") - # 매수 로직 (신호 또는 점수 기반) - # 추가매수 허용: 보유 중이어도 비중 한도 내에서 추매 가능 - if (should_buy or (score_val >= 1)): + # 폴백 종목(규칙 미매칭): 강한 신호 1개 이상 OR 약한 신호 2개 이상 + score ≥ 2 필요 + # 규칙 매칭 종목(has_rule=True)은 is_mean_reversion_candidate()를 통과했으므로 그대로 진행 + if should_buy and not has_rule: + strong_buy_kw = [ + "RSI 바닥 탈출 반등", + "볼린저 하한선 터치", + "눌림목 진입 매수타점", + "정배열 눌림목 매수", + ] + has_strong_signal = any(k in text_signals for k in strong_buy_kw) + matched_count = sum(1 for k in buy_keywords if k in text_signals) + if not has_strong_signal and (matched_count < 2 or score_val < 2): + should_buy = False + logger.info( + f"📉 복합 신호 부족으로 매수 억제: {stock_name}" + f" (신호={matched_count}개, score={score_val:.1f})" + ) + + # 매수 로직 (명시적 매수 신호 필요 — score 단독으로는 진입 불가) + # 프론트 "관망/0%" 표시와 실행 로직 일치: should_buy=True여야만 진입 + if should_buy: # 🚫 추격매수 방지: 급등 종목 필터 try: if is_domestic: @@ -2347,18 +2417,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): ) db.add(new_holding) await db.commit() - # 포트폴리오 캐시 즉시 업데이트 + # 포트폴리오 캐시 즉시 현금 차감 (미수 방지) + # 캐시 완전 초기화 대신 available_cash만 즉시 차감 → 동일 사이클 내 연속 매수 차단 try: from app.api.endpoints.portfolio import portfolio_cache - # 캐시 완전 초기화 - 다음 조회시 강제로 KIS API 호출 - portfolio_cache.holdings = [] - portfolio_cache.summary = None - portfolio_cache.last_synced = None - portfolio_cache.last_trade_id = None + order_cost = quantity * current_price + if portfolio_cache.summary: + old_cash = float(portfolio_cache.summary.get("available_cash", 0)) + portfolio_cache.summary["available_cash"] = max(0.0, old_cash - order_cost) + logger.info( + f"💸 예수금 즉시 차감: {stock_name}({stock_code}) " + f"{order_cost:,.0f}원 차감 → 잔여 {portfolio_cache.summary['available_cash']:,.0f}원" + ) portfolio_cache.change_flag = True - logger.info(f"🔄 Portfolio cache CLEARED after BUY {stock_name}({stock_code})") + portfolio_cache.last_trade_id = None # 다음 sync 강제 except Exception as e: - logger.error(f"Portfolio cache clear failed after BUY: {e}") + logger.error(f"Portfolio cache cash deduction failed after BUY: {e}") return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success} except Exception as e: @@ -2371,8 +2445,13 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # 매도 로직 elif should_sell and holding: quantity = holding.quantity - profit_loss_rate = float(holding.profit_loss_rate or 0) - + # DB 저장값은 동기화 지연으로 stale할 수 있으므로 실시간 가격으로 재계산 + _avg = float(holding.average_price or 0) + if _avg > 0 and current_price > 0: + profit_loss_rate = ((current_price - _avg) / _avg) * 100 + else: + profit_loss_rate = float(holding.profit_loss_rate or 0) + # 익절/손절 조건 확인 take_profit = stock.get("take_profit", 3.0) # 3% 익절(권장) stop_loss = stock.get("stop_loss", -1.5) # -1.5% 손절(권장) diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py index adad59f..936ef6a 100644 --- a/backend/app/api/endpoints/risk_management.py +++ b/backend/app/api/endpoints/risk_management.py @@ -471,3 +471,25 @@ async def get_win_rate( except Exception as e: logger.error(f"Error calculating win rate: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/mdd/reset") +async def reset_mdd(): + """MDD 상태 수동 초기화 (신규 진입 차단 해제)""" + from app.services.mdd_manager import mdd_manager + before = { + "is_paused": mdd_manager.is_paused, + "peak_balance": mdd_manager.peak_balance, + "reduction_applied": mdd_manager.reduction_applied, + } + mdd_manager.reset() + logger.info("✅ MDD 수동 초기화 완료") + return { + "message": "MDD 초기화 완료 — 신규 진입 가능", + "before": before, + "after": { + "is_paused": mdd_manager.is_paused, + "peak_balance": mdd_manager.peak_balance, + "reduction_applied": mdd_manager.reduction_applied, + }, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 1373d9c..3b09212 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -104,7 +104,7 @@ class Settings(BaseSettings): # MCP Server MCP_SERVER_URL: str = "http://localhost:8001" # MCP AI 검증 (자동매매 전 AI 최종 판단) - MCP_VALIDATION_ENABLED: bool = False # 기본 OFF (.env에서 True로 활성화) + MCP_VALIDATION_ENABLED: bool = True # 기본 ON — AI 검증 없이 자동매매 차단 MCP_CONFIDENCE_THRESHOLD: float = 0.5 # AI 신뢰도 최소값 (0.0~1.0) # News API diff --git a/backend/app/main.py b/backend/app/main.py index 1364eb7..80b4fae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,26 +32,55 @@ logging.basicConfig( logger = logging.getLogger(__name__) +async def batch_analysis_scheduler(): + """10분 간격 배치 분석 스케줄러. + + 장중 10분마다 KIS API로 종목 분석 후 stock_analysis DB에 upsert. + auto_trade_scheduler가 DB를 빠르게 읽어 매매 판단에 활용한다. + """ + await asyncio.sleep(15) # 부팅 안정화 (auto_trade_scheduler보다 늦게 시작) + from app.api.endpoints.recommendations import get_rule_based_recommendations_realtime + + interval = int(getattr(settings, "BATCH_ANALYSIS_INTERVAL", 600)) # 기본 10분 + logger.info(f"📊 Batch analysis scheduler started (every {interval}s)") + try: + while True: + if not is_market_open(): + await asyncio.sleep(1800) + continue + try: + logger.info("🔄 [Batch] 10-min analysis cycle starting...") + result = await get_rule_based_recommendations_realtime() + count = len(result.get("recommendations", [])) if result else 0 + logger.info(f"✅ [Batch] Done: {count} stocks saved/updated in stock_analysis DB") + except Exception as e: + logger.error(f"❌ Batch analysis error: {e}", exc_info=True) + await asyncio.sleep(interval) + except asyncio.CancelledError: + logger.info("⏹️ Batch analysis scheduler stopped") + raise + + async def auto_trade_scheduler(): """1분 간격 자동매매 스케줄러. - 실시간 분석을 수행하고, 보유 종목 손절/익절을 체크하며, 자동매매를 실행한다. + DB 캐시에서 추천 종목을 읽어 손절/익절 및 신규 매수를 실행한다. + 실제 KIS API 분석은 batch_analysis_scheduler(10분)가 담당한다. """ # 지연 시작으로 앱 부팅 안정화 await asyncio.sleep(2) from app.api.endpoints.recommendations import ( - get_rule_based_recommendations_realtime, + get_rule_based_recommendations_from_db, execute_auto_trade_background, ) from app.api.endpoints.portfolio import check_and_execute_exit_trades - logger.info("⏱️ Auto-trade scheduler started (every 60s)") + logger.info("⏱️ Auto-trade scheduler started (every 60s, DB mode)") try: while True: try: # 장 운영 시간 체크 if not is_market_open(): logger.info("💤 Market is closed. Skipping auto-trade cycle. Next check in 30 minutes.") - # 장 종료 시에는 30분(1800초) 대기하여 리소스 절약 await asyncio.sleep(1800) continue @@ -62,17 +91,17 @@ async def auto_trade_scheduler(): await check_and_execute_exit_trades(session) except Exception as e: logger.error(f"Exit trade check error: {e}", exc_info=True) - - # 2. 자동매매가 활성화된 경우에만 신규 매수 진행 + + # 2. 자동매매: DB 캐시에서 추천 조회 (빠름 - API 호출 없음) if settings.AUTO_TRADE_ENABLED: - logger.info("🔄 Starting real-time analysis cycle...") - rec = await get_rule_based_recommendations_realtime() - + logger.info("🔄 Checking DB recommendations for auto-trade...") + rec = await get_rule_based_recommendations_from_db(session) + if rec and rec.get("recommendations_by_rule"): logger.info(f"📊 Found recommendations, executing auto-trade") await execute_auto_trade_background(rec, session) else: - logger.info("⏭️ Auto-trade skipped: no recommendations matched rules") + logger.info("⏭️ Auto-trade skipped: no recommendations in DB") else: logger.info("⏸️ Auto-trade disabled - only monitoring stop-loss/take-profit") except Exception as e: @@ -260,13 +289,14 @@ async def lifespan(app: FastAPI): # 스케줄러 시작 app.state.auto_trade_task = asyncio.create_task(auto_trade_scheduler()) + app.state.batch_analysis_task = asyncio.create_task(batch_analysis_scheduler()) app.state.day_trade_task = asyncio.create_task(day_trade_scheduler()) yield # 종료 시 정리 작업 # 스케줄러 중단 - for task_attr in ("auto_trade_task", "day_trade_task"): + for task_attr in ("auto_trade_task", "batch_analysis_task", "day_trade_task"): try: task = getattr(app.state, task_attr, None) if task: diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index ce33ded..02b1bc2 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -281,18 +281,20 @@ def check_exit_signal( threshold = 0.7 if regime == MarketRegime.BULL else 0.5 # ── 2. 1차 익절: 아직 1차 안 했을 때 ────────────────────────── + # 최소 1×ATR 수익 조건 추가 — 수수료+슬리피지(~0.35%) 이하 수익에서 조기 청산 방지 + min_profit_price = entry_price + 1.0 * atr if remaining_pct > threshold: - if 55 <= rsi <= 65 and close > bb_middle: + if 55 <= rsi <= 65 and close > bb_middle and close >= min_profit_price: if regime == MarketRegime.BULL: return ExitSignal( action="SELL_PARTIAL", - reason=f"1차 익절(Bull 30%): RSI({rsi:.1f}) 55~65 회복 + BB중앙({bb_middle:.0f}) 상회", + reason=f"1차 익절(Bull 30%): RSI({rsi:.1f}) 55~65 회복 + BB중앙({bb_middle:.0f}) 상회 + 1×ATR({atr:.0f}) 수익", sell_pct=0.30, ) else: return ExitSignal( action="SELL_PARTIAL", - reason=f"1차 익절(Bear 50%): RSI({rsi:.1f}) 55~65 회복 + BB중앙({bb_middle:.0f}) 상회", + reason=f"1차 익절(Bear 50%): RSI({rsi:.1f}) 55~65 회복 + BB중앙({bb_middle:.0f}) 상회 + 1×ATR({atr:.0f}) 수익", sell_pct=0.50, ) diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..856559f --- /dev/null +++ b/backups/.gitkeep @@ -0,0 +1 @@ +# 자동 생성된 PostgreSQL 백업 폴더 diff --git a/docker-compose.yml b/docker-compose.yml index 09029db..4a4f478 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,6 +124,29 @@ services: condition: service_started restart: unless-stopped + # PostgreSQL 자동 백업 (매일 02:00 KST, 7일치 보관) + postgres-backup: + image: prodrigestivill/postgres-backup-local:15 + container_name: autostock-postgres-backup + environment: + - POSTGRES_HOST=postgres + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - SCHEDULE=0 17 * * * # UTC 17:00 = KST 02:00 + - BACKUP_KEEP_DAYS=7 + - BACKUP_KEEP_WEEKS=4 + - BACKUP_KEEP_MONTHS=3 + - HEALTHCHECK_PORT=8080 + volumes: + - ./backups:/backups + networks: + - autostock-network + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + # Frontend frontend: build: @@ -145,6 +168,7 @@ volumes: postgres_data: redis_data: rabbitmq_data: + backup_data: networks: autostock-network: diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 885aba3..6e0ec91 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react' +import { useEffect, useState, useRef, useMemo } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import api from '../lib/api' import { useWebSocket } from '../hooks/useWebSocket' @@ -7,6 +7,12 @@ import { TradingChart } from '../components/TradingChart' import { Activity, Brain, RefreshCw } from 'lucide-react' import './Dashboard.css' +const formatTime = (isoStr?: string) => { + if (!isoStr) return null; + const d = new Date(isoStr); + return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +}; + // 마켓 운영 시간 체크 (한국장 + 미국장) const checkMarketOpen = () => { const now = new Date(); @@ -89,7 +95,7 @@ const Dashboard = () => { return data; }, - refetchInterval: isMarketOpen ? 10000 : false, + refetchInterval: isMarketOpen ? 600000 : false, // 10분마다 배치 실행 // 장외에도 초기 로드는 실행 (DB 조회는 빠름) }) @@ -195,6 +201,29 @@ const Dashboard = () => { } }, [wsMessages, queryClient]) + // 보유종목 + 분석종목 전체 코드 수집 (현재가 일괄 조회용) + const allStockCodes = useMemo(() => { + const codes = new Set() + ;(portfolio || []).forEach((s: any) => codes.add(s.stock_code)) + ;(ruleBasedRecommendations?.analyzed_stock_list || []).forEach((s: any) => codes.add(s.stock_code)) + return Array.from(codes) + }, [portfolio, ruleBasedRecommendations]) + + // 현재가 일괄 실시간 조회 (보유종목·분석종목 동기화) + const { data: livePrices } = useQuery({ + queryKey: ['live-prices', allStockCodes.join(',')], + queryFn: async () => { + if (allStockCodes.length === 0) return {} + const response = await api.get('/market-data/prices', { + params: { codes: allStockCodes.join(',') } + }) + return (response.data.prices || {}) as Record + }, + enabled: allStockCodes.length > 0, + refetchInterval: isMarketOpen ? 10000 : false, + staleTime: 5000, + }) + // 보유 종목 코드 Set (추천 목록 필터링용) const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code)) @@ -230,7 +259,8 @@ const Dashboard = () => { // }, [isMarketOpen, queryClient]) const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ - queryKey: ['ai-analysis', selectedStock], + // orderbook?.stock_name을 key에 포함: 호가 데이터 로드 후 종목명이 생기면 재조회 + queryKey: ['ai-analysis', selectedStock, orderbook?.stock_name || ''], queryFn: async () => { if (!selectedStock) return null; console.log('🤖 AI 분석 요청:', selectedStock); @@ -240,8 +270,15 @@ const Dashboard = () => { const response = await api.get(`/market-data/ai-analysis/${selectedStock}`); const data = response.data; - // 분석 텍스트 생성 - let analysis = `📈 ${data.stock_name} (${data.stock_code})\n\n`; + // 분석 텍스트 생성 (종목명 없으면 여러 소스에서 순차 폴백) + // trim(): KIS API가 공백 문자열을 반환할 경우 대비 + const stockNameFallback = + orderbook?.stock_name?.trim() || + portfolio?.find((s: any) => s.stock_code === selectedStock)?.stock_name?.trim() || + (ruleBasedRecommendations?.analyzed_stock_list || []).find((s: any) => s.stock_code === selectedStock)?.stock_name?.trim() || + '' + const displayName = data.stock_name?.trim() || stockNameFallback || selectedStock + let analysis = `📈 ${displayName} (${data.stock_code})\n\n`; // 현재가 & 거래량 analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`; @@ -379,11 +416,13 @@ const Dashboard = () => {
{portfolio && portfolio.length > 0 ? ( portfolio.map((stock: any) => { - const currentPrice = Number(stock.current_price || 0); + const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0); const avgPrice = Number(stock.average_price || 0); const quantity = Number(stock.quantity || 0); const totalValue = currentPrice * quantity; - const profitLossRate = Number(stock.profit_loss_rate || 0); + const profitLossRate = avgPrice > 0 + ? ((currentPrice - avgPrice) / avgPrice) * 100 + : Number(stock.profit_loss_rate || 0); const handleSell = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -508,10 +547,17 @@ const Dashboard = () => {
{/* AI 투자 분석 */}
-

- - AI 투자 분석 -

+
+

+ + AI 투자 분석 +

+ {aiAnalysisData?.data?.analyzed_at && ( + + {formatTime(aiAnalysisData.data.analyzed_at)} + + )} +
{aiAnalysisText ? ( typeof aiAnalysisText === 'string' @@ -535,7 +581,15 @@ const Dashboard = () => { {orderbook ? (() => { // 현재가 주변 ±10단계를 10원 단위로 연속 생성 const currentPrice = orderbook.current_price; - const priceUnit = 10; + // KRX 호가 단위 (코스피/코스닥 공통) + const priceUnit = + currentPrice < 1000 ? 1 + : currentPrice < 5000 ? 5 + : currentPrice < 10000 ? 10 + : currentPrice < 50000 ? 50 + : currentPrice < 100000 ? 100 + : currentPrice < 500000 ? 500 + : 1000; const steps = 10; // 매도 호가: 현재가 + 10 ~ 현재가 + 100 (높은 가격부터) @@ -635,9 +689,16 @@ const Dashboard = () => { 분석 종목 - - {ruleBasedRecommendations?.analyzed_stocks || 0} - +
+ {ruleBasedRecommendations?.batch_time && ( + + {formatTime(ruleBasedRecommendations.batch_time)} + + )} + + {ruleBasedRecommendations?.analyzed_stocks || 0} + +
{ruleBasedRecommendations === undefined ? ( @@ -689,7 +750,7 @@ const Dashboard = () => {
- {Math.round(stock.current_price || 0).toLocaleString()}원 + {Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원 {stock.rsi != null && RSI {stock.rsi.toFixed(0)}} {stock.stochastic_k != null && K {stock.stochastic_k.toFixed(0)}}