구조적인 부분 수정
This commit is contained in:
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user