DB오류 처리중

This commit is contained in:
2026-02-23 16:01:17 +09:00
parent 138bf494db
commit 6d9c9cb010
12 changed files with 368 additions and 109 deletions

View File

@@ -19,7 +19,11 @@
"Bash(python -c:*)", "Bash(python -c:*)",
"Bash(git pull:*)", "Bash(git pull:*)",
"Bash(git stash:*)", "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, "enableAllProjectMcpServers": true,

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
# PostgreSQL 백업 파일 (용량 큼, git 추적 불필요)
backups/*.sql.gz
backups/*.dump
# Environment # Environment
.env .env
.env.local .env.local

View File

@@ -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_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, "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. 규칙 기반 분석 로직 # 3. 규칙 기반 분석 로직
recommendation = "HOLD" # 기본 추천: 보유 recommendation = "HOLD" # 기본 추천: 보유
@@ -654,7 +683,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
# 4. 종합 결과 반환 # 4. 종합 결과 반환
return { return {
"stock_code": stock_code, "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, "current_price": current_price,
"high_price": high_price, "high_price": high_price,
"low_price": low_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, "ai_analysis": analysis_text,
"recommendation": recommendation, "recommendation": recommendation,
"confidence": confidence, "confidence": confidence,
"trend": trend "trend": trend,
"analyzed_at": datetime.now().isoformat(),
} }
except HTTPException: except HTTPException:

View File

@@ -135,12 +135,14 @@ class PortfolioCache:
continue continue
return 0.0 return 0.0
# 현금: D+2 정산액 우선 -> 예수금총액 -> 순자산금액 순 # 현금: 미수금 발생 방지를 위해 실제 현재 예수금 기준 사용
# d2_depo_amt(D+2 정산액)는 미청산 매도 대금 포함으로 실제보다 높게 나올 수 있음
# → dnca_tot_amt(예수금 총액, 현재 즉시 사용 가능) 우선 사용
available_cash = pick_float(output2, [ available_cash = pick_float(output2, [
"d2_depo_amt", # D+2 정산액 "dnca_tot_amt", # 예수금 총액 (현재 실제 현금, 미수 방지 기준)
"d2_depo_amt", # D+2 정산액 (fallback)
"d2_deposit", # 일부 응답 변형 키 "d2_deposit", # 일부 응답 변형 키
"dnca_tot_amt", # 예수금 총액 "nass_amt" # 순자산금액 (최후 fallback)
"nass_amt" # 순자산금액 (fallback)
]) ])
# 총자산: KIS API의 tot_evlu_amt 사용 (가장 정확) # 총자산: KIS API의 tot_evlu_amt 사용 (가장 정확)

View File

@@ -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_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
"ml_signal": stock.ml_signal, "ml_signal": stock.ml_signal,
"price_date": stock.analysis_date.strftime("%Y%m%d"), "price_date": stock.analysis_date.strftime("%Y%m%d"),
"rule_id": 0, "rule_id": None,
"rule_name": "고점수 분석 종목", "rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목", "rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99, "rule_priority": 99,
@@ -1412,7 +1412,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
if fallback_stocks: if fallback_stocks:
recommendations_by_rule.append({ recommendations_by_rule.append({
"rule_id": 0, "rule_id": None,
"rule_name": "고점수 분석 종목", "rule_name": "고점수 분석 종목",
"rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목", "rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_type": "ANALYSIS", "rule_type": "ANALYSIS",
@@ -1442,6 +1442,11 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"recommendations": rule_matches, "recommendations": rule_matches,
"recommendations_by_rule": recommendations_by_rule, "recommendations_by_rule": recommendations_by_rule,
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules], "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, "signals": rec.signals,
"score": rec.score, "score": rec.score,
"price_date": rec.price_date, "price_date": rec.price_date,
"rule_id": 0, "rule_id": None,
"rule_name": "기본 추천", "rule_name": "기본 추천",
"rule_description": "RSI + 이동평균 기반 추천", "rule_description": "RSI + 이동평균 기반 추천",
"rule_priority": 1, "rule_priority": 1,
@@ -1578,7 +1583,7 @@ async def get_rule_based_recommendations_realtime():
"analyzed_stocks": len(stocks_to_analyze), "analyzed_stocks": len(stocks_to_analyze),
"recommendations": default_stocks, "recommendations": default_stocks,
"recommendations_by_rule": [{ "recommendations_by_rule": [{
"rule_id": 0, "rule_id": None,
"rule_name": "기본 추천 (RSI + 이동평균)", "rule_name": "기본 추천 (RSI + 이동평균)",
"rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목", "rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목",
"rule_type": "BUY", "rule_type": "BUY",
@@ -1591,11 +1596,29 @@ async def get_rule_based_recommendations_realtime():
} }
logger.info(f"Found {len(active_rules)} active trading rules") logger.info(f"Found {len(active_rules)} active trading rules")
# 분석할 종목 리스트 (타임아웃 방지를 위해 10개로 제한) # 분석할 종목 리스트 (타임아웃 방지를 위해 10개로 제한)
all_stocks = await get_all_tradable_stocks() all_stocks = await get_all_tradable_stocks()
stocks_to_analyze = all_stocks[:10] # 상위 10개 종목으로 제한 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...") logger.info(f"Analyzing {len(stocks_to_analyze)} stocks once with all indicators...")
criteria = RecommendationCriteria( criteria = RecommendationCriteria(
@@ -1603,7 +1626,8 @@ async def get_rule_based_recommendations_realtime():
use_moving_average=True, use_moving_average=True,
use_macd=True, use_macd=True,
use_bollinger=True, use_bollinger=True,
use_stochastic=True use_stochastic=True,
for_batch=True, # score=0 종목도 반환하여 모든 지표 DB 저장
) )
# 배치 처리로 종목 분석 (Rate Limiting) # 배치 처리로 종목 분석 (Rate Limiting)
@@ -1739,50 +1763,66 @@ async def get_rule_based_recommendations_realtime():
stock["current_price"] = updated["current_price"] stock["current_price"] = updated["current_price"]
stock["change_rate"] = updated["change_rate"] stock["change_rate"] = updated["change_rate"]
# 실시간 분석 결과를 DB에 저장 # 실시간 분석 결과를 DB에 저장 (규칙 매칭 여부와 무관하게 분석된 전 종목 저장)
from app.models.models import StockAnalysis from app.models.models import StockAnalysis
from sqlalchemy.ext.asyncio import AsyncSession from datetime import date as _date
from app.core.database import AsyncSessionLocal _today = _date.today()
import datetime _now = datetime.now()
async with AsyncSessionLocal() as _db:
db = AsyncSessionLocal() # valid_analyzed_stocks = StockRecommendation Pydantic 모델 리스트
today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) # top_recommendations에만 저장하면 급등과열/필터로 모두 차단될 때 아무것도 저장 안 됨
for rec in top_recommendations: for rec in valid_analyzed_stocks:
# 중복 저장 방지: 오늘자+종목코드 기준 _existing = (await _db.execute(
exists = await db.execute( select(StockAnalysis).where(
StockAnalysis.__table__.select().where( StockAnalysis.stock_code == rec.stock_code,
StockAnalysis.stock_code == rec["stock_code"], StockAnalysis.analysis_date >= _today,
StockAnalysis.analysis_date == today )
) )).scalar_one_or_none()
) if _existing:
if not exists.first(): # 기존 레코드 업데이트 (지표 갱신)
stock_obj = StockAnalysis( _existing.analysis_date = _now # 배치 실행 시각으로 갱신
stock_code=rec["stock_code"], _existing.current_price = rec.current_price
stock_name=rec["stock_name"], _existing.change_rate = rec.change_rate or 0
analysis_date=today, _existing.volume = rec.volume or 0
current_price=rec["current_price"], _existing.rsi = rec.rsi
change_rate=rec.get("change_rate", 0), _existing.ma5 = rec.ma5
volume=rec.get("volume", 0), _existing.ma20 = rec.ma20
rsi=rec.get("rsi", None), _existing.ma60 = rec.ma60
ma5=rec.get("ma5", None), _existing.stochastic_k = rec.stochastic_k
ma20=rec.get("ma20", None), _existing.stochastic_d = rec.stochastic_d
ma60=rec.get("ma60", None), _existing.bollinger_upper = rec.bollinger_upper
stochastic_k=rec.get("stochastic_k", None), _existing.bollinger_lower = rec.bollinger_lower
stochastic_d=rec.get("stochastic_d", None), _existing.bollinger_middle = rec.bollinger_middle
bollinger_upper=rec.get("bollinger_upper", None), _existing.signals = rec.signals or []
bollinger_lower=rec.get("bollinger_lower", None), _existing.score = rec.score
bollinger_middle=rec.get("bollinger_middle", None), _existing.ml_score = rec.ml_score
signals=rec.get("signals", []), _existing.ml_confidence = rec.ml_confidence
score=rec.get("score", 0), _existing.ml_signal = rec.ml_signal
ml_score=rec.get("ml_score"), else:
ml_confidence=rec.get("ml_confidence"), _db.add(StockAnalysis(
ml_signal=rec.get("ml_signal"), stock_code=rec.stock_code,
) stock_name=rec.stock_name,
db.add(stock_obj) analysis_date=_now,
try: current_price=rec.current_price,
await db.commit() change_rate=rec.change_rate or 0,
finally: volume=rec.volume or 0,
await db.close() 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 { return {
"total": len(rule_matches), "total": len(rule_matches),
"active_rules": len(active_rules), "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})" return False, f"mcp_rejected(decision={decision}, conf={confidence:.2f})"
except Exception as e: except Exception as e:
logger.warning(f"⚠️ MCP 검증 실패 (스킵하고 계속): {e}") logger.warning(f"⚠️ MCP 검증 실패 — 매매 차단: {e}")
return True, "mcp_unavailable" # 장애 시 매매 차단 안 함 return False, "mcp_unavailable" # 장애 시 매매 차단 (fail-closed)
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): 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 = [ 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) 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}") 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 has_rule:
# 이격도 과열 시 매수 금지 # 이격도 과열 시 매수 금지
if "이격도 과열" in text_signals or "매수금지" in text_signals: 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}") logger.info(f"🚫 규칙 추천이지만 급등/과열 금지: {stock_name}")
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
should_buy = True # ML이 명시적으로 HOLD를 내렸으면 규칙 기반 강제매수 금지
logger.info(f"📋 규칙 기반 추천: {stock_name} - {stock.get('rule_name', '매수규칙')}") # (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', '매수규칙')}")
# 매수 로직 (신호 또는 점수 기반) # 폴백 종목(규칙 미매칭): 강한 신호 1개 이상 OR 약한 신호 2개 이상 + score ≥ 2 필요
# 추가매수 허용: 보유 중이어도 비중 한도 내에서 추매 가능 # 규칙 매칭 종목(has_rule=True)은 is_mean_reversion_candidate()를 통과했으므로 그대로 진행
if (should_buy or (score_val >= 1)): 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: try:
if is_domestic: if is_domestic:
@@ -2347,18 +2417,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
) )
db.add(new_holding) db.add(new_holding)
await db.commit() await db.commit()
# 포트폴리오 캐시 즉시 업데이트 # 포트폴리오 캐시 즉시 현금 차감 (미수 방지)
# 캐시 완전 초기화 대신 available_cash만 즉시 차감 → 동일 사이클 내 연속 매수 차단
try: try:
from app.api.endpoints.portfolio import portfolio_cache from app.api.endpoints.portfolio import portfolio_cache
# 캐시 완전 초기화 - 다음 조회시 강제로 KIS API 호출 order_cost = quantity * current_price
portfolio_cache.holdings = [] if portfolio_cache.summary:
portfolio_cache.summary = None old_cash = float(portfolio_cache.summary.get("available_cash", 0))
portfolio_cache.last_synced = None portfolio_cache.summary["available_cash"] = max(0.0, old_cash - order_cost)
portfolio_cache.last_trade_id = None logger.info(
f"💸 예수금 즉시 차감: {stock_name}({stock_code}) "
f"{order_cost:,.0f}원 차감 → 잔여 {portfolio_cache.summary['available_cash']:,.0f}원"
)
portfolio_cache.change_flag = True 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: 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} return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success}
except Exception as e: except Exception as e:
@@ -2371,8 +2445,13 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
# 매도 로직 # 매도 로직
elif should_sell and holding: elif should_sell and holding:
quantity = holding.quantity 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% 익절(권장) take_profit = stock.get("take_profit", 3.0) # 3% 익절(권장)
stop_loss = stock.get("stop_loss", -1.5) # -1.5% 손절(권장) stop_loss = stock.get("stop_loss", -1.5) # -1.5% 손절(권장)

View File

@@ -471,3 +471,25 @@ async def get_win_rate(
except Exception as e: except Exception as e:
logger.error(f"Error calculating win rate: {e}", exc_info=True) logger.error(f"Error calculating win rate: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) 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,
},
}

View File

@@ -104,7 +104,7 @@ class Settings(BaseSettings):
# MCP Server # MCP Server
MCP_SERVER_URL: str = "http://localhost:8001" MCP_SERVER_URL: str = "http://localhost:8001"
# MCP AI 검증 (자동매매 전 AI 최종 판단) # 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) MCP_CONFIDENCE_THRESHOLD: float = 0.5 # AI 신뢰도 최소값 (0.0~1.0)
# News API # News API

View File

@@ -32,26 +32,55 @@ logging.basicConfig(
logger = logging.getLogger(__name__) 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(): async def auto_trade_scheduler():
"""1분 간격 자동매매 스케줄러. """1분 간격 자동매매 스케줄러.
실시간 분석을 수행하고, 보유 종목 손절/익절을 체크하며, 자동매매를 실행한다. DB 캐시에서 추천 종목을 읽어 손절/익절 및 신규 매수를 실행한다.
실제 KIS API 분석은 batch_analysis_scheduler(10분)가 담당한다.
""" """
# 지연 시작으로 앱 부팅 안정화 # 지연 시작으로 앱 부팅 안정화
await asyncio.sleep(2) await asyncio.sleep(2)
from app.api.endpoints.recommendations import ( from app.api.endpoints.recommendations import (
get_rule_based_recommendations_realtime, get_rule_based_recommendations_from_db,
execute_auto_trade_background, execute_auto_trade_background,
) )
from app.api.endpoints.portfolio import check_and_execute_exit_trades 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: try:
while True: while True:
try: try:
# 장 운영 시간 체크 # 장 운영 시간 체크
if not is_market_open(): if not is_market_open():
logger.info("💤 Market is closed. Skipping auto-trade cycle. Next check in 30 minutes.") logger.info("💤 Market is closed. Skipping auto-trade cycle. Next check in 30 minutes.")
# 장 종료 시에는 30분(1800초) 대기하여 리소스 절약
await asyncio.sleep(1800) await asyncio.sleep(1800)
continue continue
@@ -62,17 +91,17 @@ async def auto_trade_scheduler():
await check_and_execute_exit_trades(session) await check_and_execute_exit_trades(session)
except Exception as e: except Exception as e:
logger.error(f"Exit trade check error: {e}", exc_info=True) logger.error(f"Exit trade check error: {e}", exc_info=True)
# 2. 자동매매가 활성화된 경우에만 신규 매수 진행 # 2. 자동매매: DB 캐시에서 추천 조회 (빠름 - API 호출 없음)
if settings.AUTO_TRADE_ENABLED: if settings.AUTO_TRADE_ENABLED:
logger.info("🔄 Starting real-time analysis cycle...") logger.info("🔄 Checking DB recommendations for auto-trade...")
rec = await get_rule_based_recommendations_realtime() rec = await get_rule_based_recommendations_from_db(session)
if rec and rec.get("recommendations_by_rule"): if rec and rec.get("recommendations_by_rule"):
logger.info(f"📊 Found recommendations, executing auto-trade") logger.info(f"📊 Found recommendations, executing auto-trade")
await execute_auto_trade_background(rec, session) await execute_auto_trade_background(rec, session)
else: else:
logger.info("⏭️ Auto-trade skipped: no recommendations matched rules") logger.info("⏭️ Auto-trade skipped: no recommendations in DB")
else: else:
logger.info("⏸️ Auto-trade disabled - only monitoring stop-loss/take-profit") logger.info("⏸️ Auto-trade disabled - only monitoring stop-loss/take-profit")
except Exception as e: 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.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()) app.state.day_trade_task = asyncio.create_task(day_trade_scheduler())
yield 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: try:
task = getattr(app.state, task_attr, None) task = getattr(app.state, task_attr, None)
if task: if task:

View File

@@ -281,18 +281,20 @@ def check_exit_signal(
threshold = 0.7 if regime == MarketRegime.BULL else 0.5 threshold = 0.7 if regime == MarketRegime.BULL else 0.5
# ── 2. 1차 익절: 아직 1차 안 했을 때 ────────────────────────── # ── 2. 1차 익절: 아직 1차 안 했을 때 ──────────────────────────
# 최소 1×ATR 수익 조건 추가 — 수수료+슬리피지(~0.35%) 이하 수익에서 조기 청산 방지
min_profit_price = entry_price + 1.0 * atr
if remaining_pct > threshold: 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: if regime == MarketRegime.BULL:
return ExitSignal( return ExitSignal(
action="SELL_PARTIAL", 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, sell_pct=0.30,
) )
else: else:
return ExitSignal( return ExitSignal(
action="SELL_PARTIAL", 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, sell_pct=0.50,
) )

1
backups/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# 자동 생성된 PostgreSQL 백업 폴더

View File

@@ -124,6 +124,29 @@ services:
condition: service_started condition: service_started
restart: unless-stopped 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
frontend: frontend:
build: build:
@@ -145,6 +168,7 @@ volumes:
postgres_data: postgres_data:
redis_data: redis_data:
rabbitmq_data: rabbitmq_data:
backup_data:
networks: networks:
autostock-network: autostock-network:

View File

@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api' import api from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket' import { useWebSocket } from '../hooks/useWebSocket'
@@ -7,6 +7,12 @@ import { TradingChart } from '../components/TradingChart'
import { Activity, Brain, RefreshCw } from 'lucide-react' import { Activity, Brain, RefreshCw } from 'lucide-react'
import './Dashboard.css' 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 checkMarketOpen = () => {
const now = new Date(); const now = new Date();
@@ -89,7 +95,7 @@ const Dashboard = () => {
return data; return data;
}, },
refetchInterval: isMarketOpen ? 10000 : false, refetchInterval: isMarketOpen ? 600000 : false, // 10분마다 배치 실행
// 장외에도 초기 로드는 실행 (DB 조회는 빠름) // 장외에도 초기 로드는 실행 (DB 조회는 빠름)
}) })
@@ -195,6 +201,29 @@ const Dashboard = () => {
} }
}, [wsMessages, queryClient]) }, [wsMessages, queryClient])
// 보유종목 + 분석종목 전체 코드 수집 (현재가 일괄 조회용)
const allStockCodes = useMemo(() => {
const codes = new Set<string>()
;(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<string, number>
},
enabled: allStockCodes.length > 0,
refetchInterval: isMarketOpen ? 10000 : false,
staleTime: 5000,
})
// 보유 종목 코드 Set (추천 목록 필터링용) // 보유 종목 코드 Set (추천 목록 필터링용)
const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code)) const heldStockCodes = new Set((portfolio || []).map((s: any) => s.stock_code))
@@ -230,7 +259,8 @@ const Dashboard = () => {
// }, [isMarketOpen, queryClient]) // }, [isMarketOpen, queryClient])
const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({ const { data: aiAnalysisData, refetch: refetchAiAnalysis } = useQuery({
queryKey: ['ai-analysis', selectedStock], // orderbook?.stock_name을 key에 포함: 호가 데이터 로드 후 종목명이 생기면 재조회
queryKey: ['ai-analysis', selectedStock, orderbook?.stock_name || ''],
queryFn: async () => { queryFn: async () => {
if (!selectedStock) return null; if (!selectedStock) return null;
console.log('🤖 AI 분석 요청:', selectedStock); console.log('🤖 AI 분석 요청:', selectedStock);
@@ -240,8 +270,15 @@ const Dashboard = () => {
const response = await api.get(`/market-data/ai-analysis/${selectedStock}`); const response = await api.get(`/market-data/ai-analysis/${selectedStock}`);
const data = response.data; 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`; analysis += `💰 현재가: ${data.current_price.toLocaleString()}원\n`;
@@ -379,11 +416,13 @@ const Dashboard = () => {
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1"> <div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{portfolio && portfolio.length > 0 ? ( {portfolio && portfolio.length > 0 ? (
portfolio.map((stock: any) => { 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 avgPrice = Number(stock.average_price || 0);
const quantity = Number(stock.quantity || 0); const quantity = Number(stock.quantity || 0);
const totalValue = currentPrice * quantity; 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) => { const handleSell = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@@ -508,10 +547,17 @@ const Dashboard = () => {
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0"> <div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
{/* AI 투자 분석 */} {/* AI 투자 분석 */}
<div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col"> <div className="bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 rounded-xl p-4 shadow-lg flex flex-col">
<h3 className="font-bold flex items-center gap-2 mb-2 text-purple-400 text-sm"> <div className="flex justify-between items-center mb-2">
<Brain size={16} /> <h3 className="font-bold flex items-center gap-2 text-purple-400 text-sm">
AI 투자 분석 <Brain size={16} />
</h3> AI 투자 분석
</h3>
{aiAnalysisData?.data?.analyzed_at && (
<span className="text-[10px] text-gray-500 font-mono">
{formatTime(aiAnalysisData.data.analyzed_at)}
</span>
)}
</div>
<div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap"> <div className="bg-gray-900/50 rounded-lg p-3 text-gray-300 text-sm leading-relaxed overflow-y-auto custom-scrollbar flex-1 border border-gray-700/30 whitespace-pre-wrap">
{aiAnalysisText ? ( {aiAnalysisText ? (
typeof aiAnalysisText === 'string' typeof aiAnalysisText === 'string'
@@ -535,7 +581,15 @@ const Dashboard = () => {
{orderbook ? (() => { {orderbook ? (() => {
// 현재가 주변 ±10단계를 10원 단위로 연속 생성 // 현재가 주변 ±10단계를 10원 단위로 연속 생성
const currentPrice = orderbook.current_price; 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; const steps = 10;
// 매도 호가: 현재가 + 10 ~ 현재가 + 100 (높은 가격부터) // 매도 호가: 현재가 + 10 ~ 현재가 + 100 (높은 가격부터)
@@ -635,9 +689,16 @@ const Dashboard = () => {
<Brain className="w-4 h-4 text-purple-400" /> <Brain className="w-4 h-4 text-purple-400" />
분석 종목 분석 종목
</h3> </h3>
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full"> <div className="flex items-center gap-2">
{ruleBasedRecommendations?.analyzed_stocks || 0} {ruleBasedRecommendations?.batch_time && (
</span> <span className="text-[10px] text-gray-500 font-mono">
{formatTime(ruleBasedRecommendations.batch_time)}
</span>
)}
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
{ruleBasedRecommendations?.analyzed_stocks || 0}
</span>
</div>
</div> </div>
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1"> <div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
{ruleBasedRecommendations === undefined ? ( {ruleBasedRecommendations === undefined ? (
@@ -689,7 +750,7 @@ const Dashboard = () => {
</div> </div>
</div> </div>
<div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5"> <div className="flex justify-between items-center text-[9px] text-gray-500 mt-0.5">
<span>{Math.round(stock.current_price || 0).toLocaleString()}원</span> <span>{Math.round(livePrices?.[stock.stock_code] ?? stock.current_price ?? 0).toLocaleString()}원</span>
<span className="flex gap-2"> <span className="flex gap-2">
{stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>} {stock.rsi != null && <span>RSI {stock.rsi.toFixed(0)}</span>}
{stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>} {stock.stochastic_k != null && <span>K {stock.stochastic_k.toFixed(0)}</span>}