DB오류 처리중
This commit is contained in:
@@ -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
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
|||||||
|
# PostgreSQL 백업 파일 (용량 큼, git 추적 불필요)
|
||||||
|
backups/*.sql.gz
|
||||||
|
backups/*.dump
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 사용 (가장 정확)
|
||||||
|
|||||||
@@ -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% 손절(권장)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
1
backups/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 자동 생성된 PostgreSQL 백업 폴더
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user