구조적인 부분 수정

This commit is contained in:
2026-02-24 11:38:35 +09:00
parent 6d9c9cb010
commit 883f87753c
15 changed files with 789 additions and 672 deletions

View File

@@ -84,6 +84,12 @@ _orb_cache: Dict[str, OpeningRange] = {}
# 일일 실현 손익 (원)
_daily_pnl: float = 0.0
# 마지막으로 조회된 총자산 (is_daily_loss_exceeded 계산용 캐시)
_last_total_assets: float = 0.0
# 당일 청산 종목 (재진입 방지 — 청산 후 당일 동일 종목 재매수 차단)
_closed_today: set = set()
# 마지막 초기화 날짜
_last_reset_date: Optional[date] = None
@@ -93,11 +99,13 @@ _trade_lock = asyncio.Lock()
def _reset_if_new_day() -> None:
"""날짜가 바뀌면 일일 상태 초기화"""
global _daily_pnl, _orb_cache, _last_reset_date
global _daily_pnl, _orb_cache, _last_reset_date, _last_total_assets, _closed_today
today = _today_kst()
if _last_reset_date != today:
_daily_pnl = 0.0
_orb_cache = {}
_last_total_assets = 0.0
_closed_today = set()
_last_reset_date = today
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
@@ -169,7 +177,7 @@ async def _place_buy_order(
async def _place_sell_order(
stock_code: str,
quantity: int,
price: int = 0, # 0 = 시장가
price: int = 0, # 0 = 시장가 (None으로 변환)
) -> bool:
"""매도 주문 실행"""
try:
@@ -177,7 +185,7 @@ async def _place_sell_order(
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=price,
price=price if price > 0 else None, # 0이면 None → 시장가(01)
)
return result.get("rt_cd") == "0"
except Exception as e:
@@ -194,18 +202,24 @@ async def _record_trade(
quantity: int,
reason: str,
profit_loss: float = 0.0,
order_price: Optional[float] = None, # 주문가 (슬리피지 분석용)
) -> None:
"""TradeHistory DB 기록"""
try:
total = price * quantity
slippage = (price - order_price) if order_price is not None else None
trade = TradeHistory(
stock_code=stock_code,
stock_name=stock_name,
order_type=order_type,
order_price=order_price,
order_quantity=quantity,
executed_price=price,
executed_quantity=quantity,
total_amount=price * quantity,
profit_loss=profit_loss,
profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0,
total_amount=total,
net_profit_loss=profit_loss, # ← net_profit_loss (profit_loss 컬럼 없음)
profit_loss_rate=(profit_loss / total * 100) if total > 0 else 0,
slippage=slippage,
status="COMPLETED",
entry_reason=reason if order_type == "BUY" else None,
exit_reason=reason if order_type == "SELL" else None,
@@ -257,8 +271,11 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
result["orb_count"] = len(_orb_cache)
return result
# ── 3. 매매 가능 구간 (09:30~15:00) ──
if not is_day_trade_entry_window():
# ── 3. 매매 가능 구간 ──
in_entry_window = is_day_trade_entry_window() # 09:30~15:00
# 포지션이 있으면 입장 창 밖(15:00~15:20)에서도 청산 체크 수행
if not in_entry_window and not _positions:
result["phase"] = "outside_window"
return result
@@ -271,11 +288,18 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
exits = await _check_exits(candle_cache=_cycle_candle_cache)
result["exits"] = exits
# ── 5. 신규 진입 신호 체크 ──
# ── 5. 신규 진입 신호 체크 (진입 창 안에서만) ──
if not in_entry_window:
result["phase"] = "exit_only"
return result
if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED:
portfolio = await _get_portfolio_summary()
total_assets = portfolio["total_assets"]
available_cash = portfolio["available_cash"]
global _last_total_assets
if total_assets > 0:
_last_total_assets = total_assets # is_daily_loss_exceeded 계산용 캐시 갱신
if not is_daily_loss_exceeded(total_assets, _daily_pnl):
current_day_trade_amount = sum(
@@ -379,8 +403,10 @@ async def _check_exits(
ok = await _place_sell_order(code, quantity, price=0) # 시장가
if ok:
pnl = (current_price - pos["entry_price"]) * quantity
_daily_pnl += pnl
del _positions[code]
async with _trade_lock:
_daily_pnl += pnl
_positions.pop(code, None)
_closed_today.add(code) # 당일 재진입 차단
async with AsyncSessionLocal() as db:
await _record_trade(
@@ -432,6 +458,8 @@ async def _scan_signals(
for code, name in list(_watchlist.items()):
if code in _positions:
continue
if code in _closed_today: # 당일 청산 종목 재진입 차단
continue
try:
if candle_cache is not None and code in candle_cache:
candles_1m = candle_cache[code]
@@ -494,6 +522,9 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
return None
if signal.stock_code in _positions:
return None
if signal.stock_code in _closed_today: # 당일 청산 종목 재진입 최종 방어
logger.info(f"⛔ 당일 청산 종목 재진입 차단: {signal.stock_code}")
return None
order_no = await _place_buy_order(
signal.stock_code, quantity, int(signal.entry_price)
@@ -517,6 +548,7 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
await _record_trade(
db, signal.stock_code, signal.stock_name, "BUY",
signal.entry_price, quantity, signal.reason,
order_price=float(int(signal.entry_price)), # 주문가 = 지정가
)
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
active = ActiveTrade(
@@ -563,8 +595,10 @@ async def _execute_eod_flat() -> None:
ok = await _place_sell_order(code, pos["quantity"], price=0)
if ok:
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
_daily_pnl += pnl
del _positions[code]
async with _trade_lock:
_daily_pnl += pnl
_positions.pop(code, None)
_closed_today.add(code) # 당일 재진입 차단
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
async with AsyncSessionLocal() as db:
@@ -595,10 +629,15 @@ async def restore_positions_from_db() -> None:
strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로
인메모리 _positions를 재구성한다.
created_at은 UTC naive datetime으로 저장되므로 날짜 비교는 UTC 기준으로 수행.
한국 장중(09:00~15:30 KST = 00:00~06:30 UTC)은 UTC/KST 날짜가 항상 일치하므로
UTC 기준으로 비교해도 동일한 결과를 얻는다.
"""
if _positions:
return # 이미 포지션 있으면 스킵
today = _today_kst()
from datetime import timezone
today_utc = datetime.now(timezone.utc).date() # UTC 기준 오늘 날짜 (created_at과 동일 기준)
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
@@ -609,9 +648,16 @@ async def restore_positions_from_db() -> None:
)
rows = result.scalars().all()
for row in rows:
if row.created_at and row.created_at.date() != today:
# created_at은 UTC 저장 → UTC 날짜로 비교
if row.created_at and row.created_at.date() != today_utc:
continue
entry_price = float(row.entry_price)
# entry_time 표시: UTC → KST 변환 (UTC+9)
if row.created_at:
created_kst = row.created_at.replace(tzinfo=pytz.utc).astimezone(KST)
entry_time_str = created_kst.strftime("%H:%M:%S")
else:
entry_time_str = "??:??:??"
_positions[row.stock_code] = {
"stock_name": row.stock_name or row.stock_code,
"strategy": row.entry_reason or "unknown",
@@ -620,7 +666,7 @@ async def restore_positions_from_db() -> None:
"target_price": float(row.take_profit) if row.take_profit else entry_price * 1.03,
"quantity": row.quantity,
"highest_price": float(row.highest_since_entry or row.entry_price),
"entry_time": row.created_at.strftime("%H:%M:%S") if row.created_at else "??:??:??",
"entry_time": entry_time_str,
"reason": row.entry_reason or "",
}
if _positions:
@@ -793,7 +839,7 @@ async def get_positions():
"count": len(result),
"daily_pnl": round(_daily_pnl, 0),
"is_daily_loss_exceeded": is_daily_loss_exceeded(
sum(p["entry_price"] * p["quantity"] for p in _positions.values()),
_last_total_assets, # 사이클 캐시 사용 (포지션 가치가 아닌 총자산)
_daily_pnl,
),
}

View File

@@ -8,9 +8,16 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from typing import Optional
from datetime import datetime
from datetime import datetime, timezone
import logging
def _utc_iso(dt: datetime | None) -> str | None:
"""UTC naive datetime → ISO 8601 with +00:00 (JS가 UTC로 올바르게 파싱)"""
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc).isoformat()
from app.core.database import get_db
from app.models.models import TradeJournal
@@ -42,12 +49,12 @@ async def list_journals(
"trade_id": j.trade_id,
"stock_code": j.stock_code,
"stock_name": j.stock_name,
"journal_date": j.journal_date.isoformat() if j.journal_date else None,
"journal_date": _utc_iso(j.journal_date) if j.journal_date else None,
"content": j.content,
"strategy_name": j.strategy_name,
"mood": j.mood,
"tags": j.tags,
"created_at": j.created_at.isoformat() if j.created_at else None,
"created_at": _utc_iso(j.created_at),
}
for j in journals
]

View File

@@ -10,6 +10,9 @@
from datetime import datetime, timedelta
import random
import logging
import pytz
_KST = pytz.timezone("Asia/Seoul")
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict
@@ -693,7 +696,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
"recommendation": recommendation,
"confidence": confidence,
"trend": trend,
"analyzed_at": datetime.now().isoformat(),
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
}
except HTTPException:

View File

@@ -22,7 +22,7 @@ async def get_mcp_status():
"""MCP 서버 상태 확인"""
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=5.0)
response = await client.get(f"{settings.MCP_SERVER_URL}/health", timeout=2.0)
if response.status_code == 200:
health_data = response.json()

View File

@@ -7,6 +7,8 @@ import asyncio
import logging
import httpx
import pytz
import time
from collections import namedtuple
from app.services.korea_investment_api import korea_investment_api
from app.core.database import get_db, AsyncSessionLocal
@@ -27,6 +29,58 @@ from app.services.loss_tracker import loss_tracker
router = APIRouter()
logger = logging.getLogger(__name__)
# 매매 규칙 namedtuple (get_rule_based_recommendations_from_db/realtime 공용)
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict:
"""StockAnalysis ORM 행 → stock_dict 변환 헬퍼 (중복 제거).
rule=None 이면 '고점수 분석 종목' 폴백 값을 채운다.
"""
base = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"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"),
}
if rule is not None:
_a = actions or {}
base.update({
"rule_id": rule.id,
"rule_name": rule.name,
"rule_description": rule.description,
"rule_priority": rule.priority,
"take_profit": _a.get("take_profit", 3.0),
"stop_loss": _a.get("stop_loss", -1.5),
"max_add_buys": _a.get("max_add_buys", 3),
"add_buy_cooldown_min": _a.get("add_buy_cooldown_min", 15),
})
else:
base.update({
"rule_id": None,
"rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99,
"take_profit": 3.0,
"stop_loss": -1.5,
"max_add_buys": 2,
"add_buy_cooldown_min": 30,
})
return base
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
@@ -170,7 +224,7 @@ def analyze_custom_signals(chart_data):
close = closes[-1]
# 🔥 이격도 계산 (추격 매수 방지) - 더 엄격하게
disparity = (close / ma20) * 100
disparity = (close / ma20) * 100 if ma20 else 100
# 🚫 급등 종목 필터: 최근 3일 상승률 체크
if len(closes) >= 4:
@@ -428,6 +482,11 @@ def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict:
return {"signal": None, "description": "변동성 돌파 미달"}
# check_market_regime() 인메모리 캐시 (KIS API 반복 호출 방지)
_market_regime_cache: dict = {"result": None, "ts": 0.0}
_MARKET_REGIME_CACHE_TTL = 120 # 2분
async def check_market_regime() -> dict:
"""시장 레짐 판별 (CLAUDE.md 섹션 7.1: 200일 MA + 기울기)
@@ -444,7 +503,14 @@ async def check_market_regime() -> dict:
if not is_market_open():
return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
# 캐시 히트: TTL 내 이전 결과 반환 (KIS API 반복 호출 방지)
now_ts = time.monotonic()
if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL:
logger.debug("check_market_regime: cache hit")
return _market_regime_cache["result"]
# KODEX 200 (069500) = KOSPI 200 추종 ETF 사용 (지수코드 0001은 daily-price API 미지원)
logger.info("check_market_regime: KIS API 호출 (캐시 미스)")
kospi_chart = await korea_investment_api.get_daily_chart("069500", "D", limit=240)
if "output2" not in kospi_chart or not kospi_chart["output2"]:
@@ -480,9 +546,14 @@ async def check_market_regime() -> dict:
drop_from_ma20 = ((current_price - ma20) / ma20) * 100
def _cache_and_return(result: dict) -> dict:
_market_regime_cache["result"] = result
_market_regime_cache["ts"] = time.monotonic()
return result
# 강한 하락장: 코스피 < MA60 또는 5일 연속 하락
if current_price < ma60 or consecutive_down >= 5:
return {
return _cache_and_return({
"regime": "strong_bearish",
"regime_200d": "bear",
"message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)",
@@ -491,11 +562,11 @@ async def check_market_regime() -> dict:
"max_trades": 1,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
})
# 약세장: 코스피 < MA20
if current_price < ma20:
return {
return _cache_and_return({
"regime": "bearish",
"regime_200d": "bear",
"message": f"하락장 (코스피: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})",
@@ -504,10 +575,10 @@ async def check_market_regime() -> dict:
"max_trades": settings.BEAR_MARKET_MAX_TRADES,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
})
# 상승장: 200일 MA 기반
return {
return _cache_and_return({
"regime": "bullish",
"regime_200d": "bull" if is_bull_200 else "bear",
"message": f"상승장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})",
@@ -516,7 +587,7 @@ async def check_market_regime() -> dict:
"max_trades": settings.MAX_DAILY_TRADES,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
})
except Exception as e:
logger.error(f"시장 지수 확인 실패: {e}")
return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5}
@@ -1136,6 +1207,65 @@ async def get_rule_based_recommendations(
return result
async def _load_active_rules_from_db(db: AsyncSession) -> list:
"""trading_rules 테이블에서 활성 규칙 로드.
DB가 비어있으면 JSON 파일 → 하드코딩 폴백 순으로 시도한다.
"""
from app.models.models import TradingRule as TradingRuleModel
try:
result = await db.execute(
select(TradingRuleModel)
.where(TradingRuleModel.is_active == True)
.order_by(TradingRuleModel.priority.desc())
)
db_rules = result.scalars().all()
if db_rules:
logger.info(f"trading_rules DB에서 {len(db_rules)}개 활성 규칙 로드")
return [
Rule(id=r.id, name=r.name, description=r.description or "",
rule_type=r.rule_type, priority=r.priority,
conditions=r.conditions, actions=r.actions, is_active=True)
for r in db_rules
]
except Exception as e:
logger.warning(f"trading_rules DB 조회 실패: {e}")
# 폴백 1: JSON 파일
try:
import json as _json
from pathlib import Path as _Path
config_path = _Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = _json.load(f)
rules_data = [r for r in config.get("rules", []) if r.get("is_active")]
if rules_data:
logger.info(f"trading_rules DB 비어있음 — JSON 파일에서 {len(rules_data)}개 규칙 로드")
return [
Rule(id=i + 1, name=r["name"], description=r.get("description", ""),
rule_type=r["rule_type"].upper(), priority=r.get("priority", i + 1),
conditions=r.get("conditions", {}), actions=r.get("actions", {}),
is_active=True)
for i, r in enumerate(rules_data)
]
except Exception as e:
logger.warning(f"JSON 규칙 파일 로드 실패: {e}")
# 폴백 2: 하드코딩 기본 규칙
logger.warning("규칙 소스 없음 — 하드코딩 기본 규칙 사용")
return [
Rule(
id=1, name="RSI+볼린저 평균회귀", description="기본 평균회귀 규칙 (CLAUDE.md 섹션 6)",
rule_type="MEAN_REVERSION", priority=1,
conditions={"rsi_bull_max": 25, "rsi_bear_max": 35,
"bb_lower_pct": 3.0, "bb_upper_pct": 1.5},
actions={"take_profit": 3.0, "stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_add_buys": 2, "add_buy_cooldown_min": 30},
is_active=True
)
]
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
"""RSI+볼린저 평균회귀 매수 조건 확인 (CLAUDE.md 섹션 7.2)
@@ -1181,65 +1311,17 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"""DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)"""
logger.info("Rule-based recommendations from DB")
# JSON 파일에서 규칙 로드
import json
from pathlib import Path
from collections import namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
try:
config_path = Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
rules_data = config.get("rules", [])
active_rules = []
for idx, rule_data in enumerate(rules_data, start=1):
if rule_data.get("is_active", False):
active_rules.append(Rule(
id=idx,
name=rule_data["name"],
description=rule_data["description"],
rule_type=rule_data["rule_type"].upper(),
priority=rule_data.get("priority", idx),
conditions=rule_data.get("conditions", {}),
actions=rule_data.get("actions", {}),
is_active=True
))
logger.info(f"Loaded {len(active_rules)} active trading rules from config file")
except Exception as e:
logger.error(f"Failed to load trading rules config: {e}")
# 폴백: 기본 규칙 사용
active_rules = [
Rule(
id=1,
name="RSI 과매도 반등",
description="RSI 30 이하 과매도 종목",
rule_type="BUY",
priority=1,
conditions={"rsi_threshold": 30},
actions={
"take_profit": 5.0,
"stop_loss": 3.0,
"position_sizing": {"target_ratio": 0.15}
},
is_active=True
)
]
logger.warning(f"Using fallback rule: {len(active_rules)} rule(s)")
active_rules = await _load_active_rules_from_db(db)
logger.info(f"Found {len(active_rules)} active trading rules")
# 오늘 날짜의 분석 데이터 조회 (중복 종목은 score 높은 것 우선)
today = date.today()
# 오늘·어제 분석 데이터 조회 (KST 날짜 기준, 배치 미실행 시 전날 데이터 사용)
today_kst = datetime.now(KST).date()
since_date = today_kst - timedelta(days=1) # 어제~오늘 포함
result = await db.execute(
select(StockAnalysis)
.where(StockAnalysis.analysis_date >= today)
.order_by(StockAnalysis.score.desc())
.where(StockAnalysis.analysis_date >= since_date)
.order_by(StockAnalysis.analysis_date.desc(), StockAnalysis.score.desc())
)
raw_stocks = result.scalars().all()
# 종목코드 기준 중복 제거 (score 내림차순 정렬 후 첫 번째만)
@@ -1264,7 +1346,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"message": "⚠️ 배치 분석 데이터 없음. 'make batch-test' 명령어로 분석 실행 필요."
}
logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today}")
logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today_kst}")
# 규칙별로 매칭되는 종목 찾기
rule_matches = []
@@ -1313,38 +1395,13 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
# 여기까지 왔으면 rule_matched_stocks에 추가
rule_matched_stocks.append(stock)
# 점수순 정렬
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
# 점수순 정렬 (score=None 안전 처리)
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
# 이하 로직은 그대로 (Top N, rule_matches, stock_to_rules, 실시간 가격 업데이트 등)
rule_stocks = []
for stock in rule_matched_stocks[:10]:
stock_dict = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"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": rule.id,
"rule_name": rule.name,
"rule_description": rule.description,
"rule_priority": rule.priority,
"take_profit": actions.get("take_profit", 3.0),
"stop_loss": actions.get("stop_loss", -1.5),
"max_add_buys": actions.get("max_add_buys", 3),
"add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15),
}
stock_dict = _stock_analysis_to_dict(stock, rule=rule, actions=actions)
rule_stocks.append(stock_dict)
rule_matches.append(stock_dict)
@@ -1381,32 +1438,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
if stock.stock_code in already_added:
continue
already_added.add(stock.stock_code)
stock_dict = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"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": None,
"rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99,
"take_profit": 3.0,
"stop_loss": -1.5,
"max_add_buys": 2,
"add_buy_cooldown_min": 30,
}
stock_dict = _stock_analysis_to_dict(stock)
fallback_stocks.append(stock_dict)
rule_matches.append(stock_dict)
@@ -1442,82 +1474,23 @@ 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()
# DB에 저장된 실제 배치 분석 날짜 (TIMESTAMP/DATE 모두 YYYY-MM-DD 반환)
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
max((s.analysis_date for s in all_analyzed_stocks), default=date.today())
).isoformat(),
}
async def get_rule_based_recommendations_realtime():
"""
실시간 KIS API 분석 (느림 - 30초)
기존 방식 유지
실시간 KIS API 분석 (느림 - 배치 주기에 호출됨)
규칙은 trading_rules DB → JSON 파일 → 하드코딩 순으로 로드한다.
"""
# TODO: 데이터베이스 연결 후 실제 규칙 조회
# 현재는 샘플 규칙으로 동작
logger.info("Rule-based recommendations requested (realtime mode)")
# 샘플 규칙 생성 (실제로는 DB에서 조회)
from collections import namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
active_rules = [
Rule(
id=1,
name="RSI 과매도 반등",
description="RSI 40 이하 과매도 종목",
rule_type="BUY",
priority=1,
conditions={"rsi_threshold": 40},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
Rule(
id=2,
name="골든크로스 추세",
description="5일선이 20일선 돌파",
rule_type="BUY",
priority=2,
conditions={"sma_short": 5, "sma_long": 20},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
Rule(
id=3,
name="볼린저밴드 하단",
description="볼린저밴드 하단 근처 반등",
rule_type="BUY",
priority=3,
conditions={"bb_threshold": 0.2},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
]
async with AsyncSessionLocal() as _db:
active_rules = await _load_active_rules_from_db(_db)
logger.info(f"Found {len(active_rules)} active trading rules (realtime)")
# active_rules가 비어있으면 기본 지표로 분석
if not active_rules:
@@ -1597,9 +1570,9 @@ 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개 종목으로 제한
stocks_to_analyze = all_stocks[:settings.ANALYSIS_STOCK_COUNT]
# 보유 포트폴리오 종목 추가 (보유 종목은 거래대금 순위 무관하게 항상 분석)
try:
@@ -1653,54 +1626,44 @@ async def get_rule_based_recommendations_realtime():
logger.info(f"Successfully analyzed {len(valid_analyzed_stocks)} stocks")
# 시장 레짐 판별 (DB 모드와 동일한 로직)
market_regime_info = await check_market_regime()
current_market_regime_str = market_regime_info.get("regime_200d", "bear")
# 규칙별로 매칭되는 종목 찾기 (이미 분석된 데이터 재사용)
rule_matches = []
recommendations_by_rule = []
for rule in active_rules:
logger.info(f"Applying rule: {rule.name}")
# 규칙 조건 파싱
conditions = rule.conditions
actions = rule.actions
# 이미 분석된 종목들 중에서 규칙에 맞는 종목 필터링
rule_matched_stocks = []
for stock in valid_analyzed_stocks:
# ETF/레버리지/인버스/KODEX/선물 종목 제외
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
if any(k in stock.stock_name for k in etf_keywords):
continue
# 규칙 조건 체크
matches = True
# RSI 조건 체크
if "rsi_threshold" in conditions:
rsi_threshold = conditions["rsi_threshold"]
if not any(f"RSI" in signal for signal in stock.signals):
matches = False
# Trend filter: only allow long entries in uptrend
if TREND_FILTER_ENABLED:
try:
t_ok = False
if stock.ma20 is not None and stock.ma60 is not None:
t_ok = (stock.ma20 > stock.ma60) and (stock.current_price > stock.ma20)
else:
# Fallback compute from price history is skipped here (already done in analyze_stock)
t_ok = False
if not t_ok:
matches = False
except Exception:
matches = False
# === 통합 규칙 조건 매칭 (DB 모드와 동일한 로직) ===
if rule.rule_type == "MEAN_REVERSION":
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime_str)
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
matches = False # 미구현 (DB 모드와 동일)
else:
# BUY 타입: RSI 임계값 체크
rsi_threshold = conditions.get("rsi_threshold", 40)
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
if matches:
rule_matched_stocks.append(stock)
# 점수순 정렬
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
# 점수순 정렬 (score=None 안전 처리)
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
# 규칙별로 종목 저장
rule_stocks = []
for rec in rule_matched_stocks[:10]: # 규칙당 상위 10개
@@ -1764,65 +1727,15 @@ async def get_rule_based_recommendations_realtime():
stock["change_rate"] = updated["change_rate"]
# 실시간 분석 결과를 DB에 저장 (규칙 매칭 여부와 무관하게 분석된 전 종목 저장)
from app.models.models import StockAnalysis
# N+1 SELECT 루프 → 단일 bulk UPSERT로 대체 (성능 개선)
from app.services.analysis_db_service import bulk_upsert_analysis as _bulk_upsert
from datetime import date as _date
_today = _date.today()
_now = datetime.now()
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
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,
))
saved = await _bulk_upsert(_db, _items, _today)
await _db.commit()
logger.info(f"✅ [Batch] DB upsert 완료: {len(valid_analyzed_stocks)}종목 저장")
logger.info(f"✅ [Batch] DB upsert 완료: {saved}종목 저장")
return {
"total": len(rule_matches),
"active_rules": len(active_rules),
@@ -2614,7 +2527,7 @@ async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
if total_processed >= max_trades:
break
for stock in rule_group["stocks"][:2]: # 규칙당 상위 2개만
for stock in rule_group["stocks"][:settings.AUTO_TRADE_TOP_N_PER_RULE]:
if total_processed >= max_trades:
break