구조적인 부분 수정
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
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ class Settings(BaseSettings):
|
||||
STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2)
|
||||
TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값
|
||||
AUTO_TRADE_ENABLED: bool = True
|
||||
ANALYSIS_STOCK_COUNT: int = 150 # 배치 분석 종목 수 (.env로 조정 가능)
|
||||
AUTO_TRADE_TOP_N_PER_RULE: int = 2 # 규칙당 자동매매 실행 최대 종목 수
|
||||
|
||||
# 포지션 규칙 (섹션 6.1)
|
||||
MAX_HOLDINGS_COUNT: int = 20 # 동시 보유 최대 20종목
|
||||
|
||||
@@ -24,9 +24,23 @@ from app.core.market_schedule import is_market_open
|
||||
from sqlalchemy import text
|
||||
|
||||
# 로깅 설정
|
||||
import os
|
||||
import logging.handlers
|
||||
_log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
|
||||
_log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
_log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs")
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_log_file = os.path.join(_log_dir, "backend.log")
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
level=_log_level,
|
||||
format=_log_format,
|
||||
handlers=[
|
||||
logging.StreamHandler(),
|
||||
logging.handlers.RotatingFileHandler(
|
||||
_log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -191,6 +205,20 @@ async def lifespan(app: FastAPI):
|
||||
ADD COLUMN IF NOT EXISTS ml_confidence NUMERIC(5, 4),
|
||||
ADD COLUMN IF NOT EXISTS ml_signal VARCHAR(10);
|
||||
"""))
|
||||
# UPSERT를 위한 unique constraint — init-db.sql에 없을 경우 자동 생성
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'uq_stock_analysis_code_date'
|
||||
AND conrelid = 'stock_analysis'::regclass
|
||||
) THEN
|
||||
ALTER TABLE stock_analysis
|
||||
ADD CONSTRAINT uq_stock_analysis_code_date
|
||||
UNIQUE (stock_code, analysis_date);
|
||||
END IF;
|
||||
END $$;
|
||||
"""))
|
||||
# active_trades에 분할 청산 컬럼 추가 (CLAUDE.md 섹션 7.3)
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE active_trades
|
||||
|
||||
@@ -178,11 +178,14 @@ class MCPToolCall(Base):
|
||||
class StockAnalysis(Base):
|
||||
"""매일 배치로 저장되는 종목 분석 데이터"""
|
||||
__tablename__ = "stock_analysis"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("stock_code", "analysis_date", name="uq_stock_analysis_code_date"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100), nullable=False)
|
||||
analysis_date = Column(DateTime, nullable=False, index=True)
|
||||
analysis_date = Column(Date, nullable=False, index=True)
|
||||
|
||||
# 가격 정보
|
||||
current_price = Column(Integer, nullable=False)
|
||||
|
||||
196
backend/app/services/analysis_db_service.py
Normal file
196
backend/app/services/analysis_db_service.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""StockAnalysis DB 서비스 — 단일 진실 공급원 (Single Source of Truth)
|
||||
|
||||
StockAnalysis ORM 저장 로직을 중앙화하여 3곳의 중복 제거:
|
||||
- batch_daily_analysis.py (bulk)
|
||||
- batch_intraday_analysis.py (per-stock, rescore)
|
||||
|
||||
PostgreSQL UPSERT(ON CONFLICT DO UPDATE)를 사용하여
|
||||
DELETE → INSERT 사이의 race condition을 원자적으로 해결.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import StockAnalysis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.api.endpoints.recommendations import StockRecommendation
|
||||
|
||||
|
||||
def passes_price_filter(result: "StockRecommendation") -> bool:
|
||||
"""5,000원 이하 저가주 필터 (CLAUDE.md 5절).
|
||||
|
||||
True → 통과 (거래 가능), False → 필터됨 (5,000원 미만).
|
||||
"""
|
||||
return not (result.current_price and result.current_price < 5000)
|
||||
|
||||
|
||||
# UPSERT 시 갱신할 컬럼 집합 (PK·unique key·최초등록 시각 제외)
|
||||
_UPSERT_UPDATE_COLS = frozenset({
|
||||
"stock_name", "current_price", "change_rate", "volume",
|
||||
"rsi", "ma5", "ma20", "ma60",
|
||||
"stochastic_k", "stochastic_d",
|
||||
"bollinger_upper", "bollinger_lower", "bollinger_middle",
|
||||
"signals", "score",
|
||||
"ml_score", "ml_confidence", "ml_signal",
|
||||
"atr_14", "adx_14",
|
||||
"updated_at",
|
||||
})
|
||||
|
||||
|
||||
def to_analysis_dict(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
result: "StockRecommendation",
|
||||
analysis_date: date,
|
||||
) -> dict:
|
||||
"""StockRecommendation → StockAnalysis INSERT 딕셔너리 변환.
|
||||
|
||||
ORM 객체 대신 dict를 반환하여 단건 UPSERT와 bulk INSERT에서 모두 재사용.
|
||||
"""
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"analysis_date": analysis_date,
|
||||
"current_price": result.current_price,
|
||||
"change_rate": result.change_rate,
|
||||
"volume": result.volume,
|
||||
"rsi": result.rsi,
|
||||
"ma5": result.ma5,
|
||||
"ma20": result.ma20,
|
||||
"ma60": result.ma60,
|
||||
"stochastic_k": result.stochastic_k,
|
||||
"stochastic_d": result.stochastic_d,
|
||||
"bollinger_upper": result.bollinger_upper,
|
||||
"bollinger_lower": result.bollinger_lower,
|
||||
"bollinger_middle": result.bollinger_middle,
|
||||
"signals": result.signals,
|
||||
"score": result.score,
|
||||
"ml_score": result.ml_score,
|
||||
"ml_confidence": result.ml_confidence,
|
||||
"ml_signal": result.ml_signal,
|
||||
"atr_14": result.atr_14,
|
||||
"adx_14": result.adx_14,
|
||||
"updated_at": datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
}
|
||||
|
||||
|
||||
async def upsert_analysis(
|
||||
session: AsyncSession,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
result: "StockRecommendation",
|
||||
analysis_date: date,
|
||||
) -> None:
|
||||
"""단건 UPSERT — 동일 (stock_code, analysis_date)가 있으면 UPDATE, 없으면 INSERT.
|
||||
|
||||
DELETE+INSERT 대비:
|
||||
- 원자적 연산 (race condition 없음)
|
||||
- 단일 DB 왕복 (네트워크 절반)
|
||||
"""
|
||||
row = to_analysis_dict(stock_code, stock_name, result, analysis_date)
|
||||
stmt = pg_insert(StockAnalysis).values(**row)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint="uq_stock_analysis_code_date",
|
||||
set_={col: stmt.excluded[col] for col in _UPSERT_UPDATE_COLS},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
async def bulk_upsert_analysis(
|
||||
session: AsyncSession,
|
||||
items: list[tuple[str, str, "StockRecommendation"]],
|
||||
analysis_date: date,
|
||||
) -> int:
|
||||
"""다건 UPSERT — 단일 INSERT ... ON CONFLICT 배치 실행.
|
||||
|
||||
items: [(stock_code, stock_name, StockRecommendation), ...]
|
||||
Returns: 처리된 건수
|
||||
"""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
rows = [to_analysis_dict(code, name, r, analysis_date) for code, name, r in items]
|
||||
stmt = pg_insert(StockAnalysis).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
constraint="uq_stock_analysis_code_date",
|
||||
set_={col: stmt.excluded[col] for col in _UPSERT_UPDATE_COLS},
|
||||
)
|
||||
await session.execute(stmt)
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def run_db_scanner(analysis_date: date) -> set[str]:
|
||||
"""배치 DB 기반 스캐너 — KosdaqScanner와 동일한 기준을 단일일 데이터로 적용.
|
||||
|
||||
저장된 RSI / 볼린저 / 거래량 데이터만 사용하므로 추가 API 호출 없음.
|
||||
|
||||
조건 (성능 중심 필터):
|
||||
- RSI(14) ≤ 30 (과매도)
|
||||
- 종가 ≤ BB 하단 × 1.015 (BB 하단 근처)
|
||||
- 시세 ≥ 5,000원
|
||||
- ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합)
|
||||
- MA5 ≥ MA20 × 0.95 (ADX 방향 proxy — 강한 하락 추세 종목 제외)
|
||||
|
||||
Returns: 신호 종목 코드 set
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
StockAnalysis.stock_code,
|
||||
StockAnalysis.current_price,
|
||||
StockAnalysis.rsi,
|
||||
StockAnalysis.bollinger_lower,
|
||||
StockAnalysis.adx_14,
|
||||
StockAnalysis.ma5,
|
||||
StockAnalysis.ma20,
|
||||
).where(
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
StockAnalysis.current_price >= 5000,
|
||||
StockAnalysis.rsi.isnot(None),
|
||||
StockAnalysis.rsi <= 30,
|
||||
StockAnalysis.bollinger_lower.isnot(None),
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
signal_tickers: set[str] = set()
|
||||
for row in rows:
|
||||
price, bb_lower = row.current_price, row.bollinger_lower
|
||||
if not bb_lower or bb_lower == 0:
|
||||
continue
|
||||
|
||||
# BB 하단선 근처 확인 (-inf ~ +1.5%)
|
||||
pct = (price - bb_lower) / bb_lower * 100
|
||||
if pct > 1.5:
|
||||
continue
|
||||
|
||||
# 구조적 추세장(ADX > 30)는 평균회귀에 부적합
|
||||
adx = row.adx_14
|
||||
if adx is not None and adx > 30:
|
||||
continue
|
||||
|
||||
# ADX 방향 proxy: MA5 < MA20 × 0.95 → 강한 하락 추세 → 평균회귀 부적합
|
||||
ma5, ma20 = row.ma5, row.ma20
|
||||
if ma5 is not None and ma20 is not None and ma20 > 0:
|
||||
if float(ma5) < float(ma20) * 0.95:
|
||||
continue
|
||||
|
||||
signal_tickers.add(row.stock_code)
|
||||
|
||||
logger.info("🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30 + MA방향)", len(signal_tickers))
|
||||
return signal_tickers
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("❌ DB 스캐너 실패: %s", exc, exc_info=True)
|
||||
return set()
|
||||
@@ -4,14 +4,15 @@
|
||||
전체 거래 종목을 분석하여 DB에 저장
|
||||
1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS)
|
||||
2. 차트 데이터 조회 + 기술적 지표 계산
|
||||
3. 매매 신호 생성 + DB 일괄 저장 (BULK INSERT)
|
||||
3. 매매 신호 생성 + DB Streaming 저장 (분석 즉시 UPSERT)
|
||||
4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이)
|
||||
5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치
|
||||
|
||||
성능 개선 (이전 대비):
|
||||
- BATCH_CONCURRENCY: 5 → 10 (API 세마포어 허용 범위 내)
|
||||
- DB 트랜잭션: 종목당 2개(DELETE+INSERT) → BULK_SAVE_CHUNK(20개)당 1개
|
||||
- 500종목 기준: 1,000 트랜잭션 → 25 트랜잭션 (40× 감소)
|
||||
- DB 트랜잭션: 종목당 2개(DELETE+INSERT) → UPSERT (원자적, race-condition 없음)
|
||||
- Streaming: 전체 메모리 적재 → 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 저장
|
||||
- DB 엔진: 스크립트 자체 생성 → app.core.database 공통 모듈 사용
|
||||
|
||||
실행: python batch_daily_analysis.py
|
||||
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py
|
||||
@@ -28,13 +29,10 @@ KST = pytz.timezone('Asia/Seoul')
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import StockAnalysis
|
||||
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI, korea_investment_api
|
||||
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch, StockRecommendation
|
||||
import logging
|
||||
|
||||
@@ -44,32 +42,11 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# DB 연결
|
||||
# -------------------------------------------------------------------
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20
|
||||
)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 성능 설정
|
||||
# -------------------------------------------------------------------
|
||||
BATCH_CONCURRENCY = 10 # ↑ 5→10: API 세마포어 허용 범위 내 동시 처리 수
|
||||
BULK_SAVE_CHUNK = 20 # DB 일괄 저장 단위 (트랜잭션 수 = 총종목 / BULK_SAVE_CHUNK)
|
||||
BATCH_CONCURRENCY = 10 # API 세마포어 허용 범위 내 동시 처리 수
|
||||
BULK_SAVE_CHUNK = 20 # Streaming 저장 단위 (큐에 20개 쌓이면 즉시 UPSERT)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -92,13 +69,13 @@ async def detect_and_log_market_regime() -> str:
|
||||
# KOSPI 지수 코드 1001
|
||||
df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001")
|
||||
if df is None or len(df) < 205:
|
||||
logger.warning("⚠️ KOSPI 데이터 부족(열 %d) — 레짐 판별 스킵", 0 if df is None else len(df))
|
||||
logger.warning("⚠️ KOSPI 데이터 부족(행 %d) — 레짐 판별 스킵", 0 if df is None else len(df))
|
||||
return "UNKNOWN"
|
||||
|
||||
kospi_closes = df["종가"].tolist()
|
||||
regime = detect_market_regime(kospi_closes)
|
||||
|
||||
regime_emoji = {"승장": "📈 BULL", "하락장": "📉 BEAR",
|
||||
regime_emoji = {"상승장": "📈 BULL", "하락장": "📉 BEAR",
|
||||
"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper())
|
||||
logger.info("🌏 시장 레짐: %s (KOSPI %s일 기준)", regime_emoji, today)
|
||||
return regime.value.upper()
|
||||
@@ -140,17 +117,18 @@ async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500)
|
||||
name = pykrx_stock.get_market_ticker_name(ticker)
|
||||
if any(k in name for k in SKIP_KEYWORDS):
|
||||
continue
|
||||
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"]))
|
||||
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"], market))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.error("❌ %s 종목 조회 실패: %s", market, exc)
|
||||
|
||||
stocks_with_cap.sort(key=lambda x: x[2], reverse=True)
|
||||
top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]]
|
||||
top_with_mkt = stocks_with_cap[:limit]
|
||||
top_stocks = [(code, name) for code, name, _, _mkt in top_with_mkt]
|
||||
|
||||
kospi_n = sum(1 for c, _ in top_stocks if c.startswith(('0','1','2','3','4','5')))
|
||||
kosdaq_n = len(top_stocks) - kospi_n
|
||||
kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI")
|
||||
kosdaq_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSDAQ")
|
||||
logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
|
||||
return top_stocks
|
||||
|
||||
@@ -169,7 +147,7 @@ async def analyze_one_stock(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
) -> tuple[str, Optional[StockRecommendation]]:
|
||||
"""종목 분석만 수행하고 결과 반환. DB 저장은 bulk_save_results에서 일괄 처리.
|
||||
"""종목 분석만 수행하고 결과 반환. DB 저장은 Streaming 저장기에서 처리.
|
||||
|
||||
Returns: (status, result)
|
||||
status: "ok" | "no_chart" | "price_filter" | "error"
|
||||
@@ -182,8 +160,8 @@ async def analyze_one_stock(
|
||||
logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
|
||||
return "no_chart", None
|
||||
|
||||
# 5,000원 이하 저가주 제외 (CLAUDE.md 6.4)
|
||||
if result.current_price and result.current_price < 5000:
|
||||
# 5,000원 이하 저가주 제외 (CLAUDE.md 5절 가격 필터)
|
||||
if not passes_price_filter(result):
|
||||
logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price)
|
||||
return "price_filter", None
|
||||
|
||||
@@ -197,153 +175,55 @@ async def analyze_one_stock(
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 4. StockRecommendation → StockAnalysis ORM 변환 헬퍼
|
||||
# 4. Streaming 저장기 — 분석 완료 즉시 BULK_SAVE_CHUNK 단위 UPSERT
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _to_analysis_model(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
result: StockRecommendation,
|
||||
async def _streaming_saver(
|
||||
queue: asyncio.Queue,
|
||||
analysis_date: date,
|
||||
) -> StockAnalysis:
|
||||
"""StockRecommendation Pydantic 모델 → StockAnalysis ORM 객체 변환"""
|
||||
return StockAnalysis(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
volume=result.volume,
|
||||
rsi=result.rsi,
|
||||
ma5=result.ma5,
|
||||
ma20=result.ma20,
|
||||
ma60=result.ma60,
|
||||
stochastic_k=result.stochastic_k,
|
||||
stochastic_d=result.stochastic_d,
|
||||
bollinger_upper=result.bollinger_upper,
|
||||
bollinger_lower=result.bollinger_lower,
|
||||
bollinger_middle=result.bollinger_middle,
|
||||
signals=result.signals,
|
||||
score=result.score,
|
||||
ml_score=result.ml_score,
|
||||
ml_confidence=result.ml_confidence,
|
||||
ml_signal=result.ml_signal,
|
||||
atr_14=result.atr_14,
|
||||
adx_14=result.adx_14,
|
||||
)
|
||||
) -> dict:
|
||||
"""큐에서 분석 결과를 꺼내 BULK_SAVE_CHUNK(20)개마다 즉시 DB UPSERT.
|
||||
|
||||
전체 메모리 적재 없이 분석 완료 즉시 저장하여 메모리 사용 최소화.
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 5. Bulk DB 저장 (BULK_SAVE_CHUNK 개씩 한 트랜잭션)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def bulk_save_results(
|
||||
items: list[tuple[str, str, StockRecommendation]],
|
||||
analysis_date: date,
|
||||
) -> int:
|
||||
"""분석 결과를 한 트랜잭션으로 일괄 저장.
|
||||
|
||||
기존 코드: 종목당 DELETE + INSERT → 2 트랜잭션 × 500종목 = 1,000 트랜잭션
|
||||
개선 코드: IN 쿼리 DELETE 1회 + add_all INSERT 1회 = 2 트랜잭션 × 25청크 = 50 트랜잭션
|
||||
|
||||
items: [(stock_code, stock_name, result), ...]
|
||||
Returns: 저장된 건수
|
||||
Returns: stats dict {"ok", "no_chart", "price_filter", "error", "saved"}
|
||||
"""
|
||||
if not items:
|
||||
return 0
|
||||
buffer: list[tuple[str, str, StockRecommendation]] = []
|
||||
stats = {"ok": 0, "no_chart": 0, "price_filter": 0, "error": 0, "saved": 0}
|
||||
|
||||
codes = [code for code, _, _ in items]
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None: # sentinel: 분석 완료
|
||||
break
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 기존 당일 데이터 일괄 삭제 (IN 쿼리 1회)
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code.in_(codes),
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
)
|
||||
# 일괄 삽입 (add_all → 단일 INSERT 배치)
|
||||
objs = [_to_analysis_model(code, name, r, analysis_date) for code, name, r in items]
|
||||
session.add_all(objs)
|
||||
await session.commit()
|
||||
status, code, name, result = item
|
||||
stats[status] = stats.get(status, 0) + 1
|
||||
|
||||
return len(objs)
|
||||
if status == "ok" and result:
|
||||
buffer.append((code, name, result))
|
||||
|
||||
# 버퍼가 청크 크기에 도달하면 즉시 저장
|
||||
if len(buffer) >= BULK_SAVE_CHUNK:
|
||||
async with AsyncSessionLocal() as session:
|
||||
saved = await bulk_upsert_analysis(session, buffer, analysis_date)
|
||||
await session.commit()
|
||||
stats["saved"] += saved
|
||||
logger.info("💾 DB 저장: %d건 누적", stats["saved"])
|
||||
buffer.clear()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 6. DB 스캐너 — 배치 완료 후 추가 API 호출 없이 신호 종목 추출
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def run_db_scanner(analysis_date: date) -> set[str]:
|
||||
"""배치 DB 기반 스캐너 — KosdaqScanner와 동일한 기준을 단일일 데이터로 적용.
|
||||
|
||||
저장된 RSI / 볼린저 / 거래량 데이터만 사용하므로 추가 API 호출 없음.
|
||||
|
||||
조건 (성능 중심 필터):
|
||||
- RSI(14) ≤ 30 (과매도)
|
||||
- 종가 ≤ BB 하단 × 1.015 (BB 하단 근처)
|
||||
- 시세 ≥ 5,000원
|
||||
- ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합)
|
||||
- MA5 ≥ MA20 × 0.95 (ADX 방향 proxy — 강한 하락 추세 종목 제외)
|
||||
|
||||
Returns: 신호 종목 코드 set
|
||||
"""
|
||||
try:
|
||||
# 나머지 잔여분 저장
|
||||
if buffer:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
StockAnalysis.stock_code,
|
||||
StockAnalysis.current_price,
|
||||
StockAnalysis.rsi,
|
||||
StockAnalysis.bollinger_lower,
|
||||
StockAnalysis.adx_14,
|
||||
StockAnalysis.ma5,
|
||||
StockAnalysis.ma20,
|
||||
).where(
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
StockAnalysis.current_price >= 5000,
|
||||
StockAnalysis.rsi.isnot(None),
|
||||
StockAnalysis.rsi <= 30,
|
||||
StockAnalysis.bollinger_lower.isnot(None),
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
saved = await bulk_upsert_analysis(session, buffer, analysis_date)
|
||||
await session.commit()
|
||||
stats["saved"] += saved
|
||||
logger.info("💾 DB 저장(잔여): %d건 누적", stats["saved"])
|
||||
|
||||
signal_tickers: set[str] = set()
|
||||
for row in rows:
|
||||
price, bb_lower = row.current_price, row.bollinger_lower
|
||||
if not bb_lower or bb_lower == 0:
|
||||
continue
|
||||
|
||||
# BB 하단선 근처 확인 (-inf ~ +1.5%)
|
||||
pct = (price - bb_lower) / bb_lower * 100
|
||||
if pct > 1.5:
|
||||
continue
|
||||
|
||||
# 구조적 추세장(ADX > 30)는 평균회귀에 부적합
|
||||
adx = row.adx_14
|
||||
if adx is not None and adx > 30:
|
||||
continue
|
||||
|
||||
# ADX 방향 proxy: MA5 < MA20 × 0.95 → 강한 하락 추세 → 평균회귀 부적합
|
||||
# DI+/DI- 컬럼이 없으므로 MA 방향으로 추세 하락 여부 판단
|
||||
ma5, ma20 = row.ma5, row.ma20
|
||||
if ma5 is not None and ma20 is not None and ma20 > 0:
|
||||
if float(ma5) < float(ma20) * 0.95:
|
||||
continue
|
||||
|
||||
signal_tickers.add(row.stock_code)
|
||||
|
||||
logger.info("🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30 + MA방향)", len(signal_tickers))
|
||||
return signal_tickers
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("❌ DB 스캐너 실패: %s", exc, exc_info=True)
|
||||
return set()
|
||||
return stats
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 7. 메인 배치
|
||||
# 6. 메인 배치
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def batch_analyze_all_stocks(limit: int = None):
|
||||
@@ -353,13 +233,14 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
1. 시장 레짐 판별 (KOSPI 지수 기준)
|
||||
2. 시가총액 상위 500개 종목 조회
|
||||
3. 세마포어로 BATCH_CONCURRENCY(10)개씩 병렬 분석 (API 호출만)
|
||||
4. 분석 완료 후 BULK_SAVE_CHUNK(20)개씩 일괄 DB 저장
|
||||
4. Streaming: 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 DB UPSERT
|
||||
5. DB 스캐너 실행 (추가 API 호출 없음)
|
||||
6. 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||
|
||||
성능 개선 (기존 대비):
|
||||
- DB 트랜잭션: 1,000회 → 50회 (20× 감소)
|
||||
- 동시 처리: 5 → 10 종목
|
||||
- DB 트랜잭션: DELETE+INSERT race-condition → 원자적 UPSERT
|
||||
- 메모리: 전체 결과 적재 → Streaming(분석 즉시 저장)
|
||||
- DB 엔진: 스크립트 자체 생성 → 공통 모듈 공유
|
||||
"""
|
||||
start_time = datetime.now(KST)
|
||||
analysis_date = datetime.now(KST).date()
|
||||
@@ -371,45 +252,47 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
# Step 1: 시장 레짐 판별
|
||||
regime = await detect_and_log_market_regime()
|
||||
|
||||
# Step 2: 종목 목록 조회
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
all_stocks = await get_all_tradable_stocks(kis_api, limit=500)
|
||||
# Step 2: 종목 목록 조회 (모듈 레벨 싱글톤 재사용, 불필요한 새 인스턴스 생성 금지)
|
||||
all_stocks = await get_all_tradable_stocks(korea_investment_api, limit=500)
|
||||
filtered_stocks = all_stocks[:limit] if limit else all_stocks
|
||||
total = len(filtered_stocks)
|
||||
logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime)
|
||||
|
||||
# Step 3: 병렬 분석 — 세마포어로 BATCH_CONCURRENCY 제한
|
||||
# asyncio 단일 스레드이므로 results 리스트 append는 thread-safe
|
||||
# Step 3 + 4: 병렬 분석 & Streaming 저장
|
||||
# 분석 태스크 → queue → _streaming_saver (동시 실행)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=BULK_SAVE_CHUNK * 3)
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
all_results: list[tuple[str, str, str, Optional[StockRecommendation]]] = []
|
||||
|
||||
async def analyze_one(i: int, code: str, name: str):
|
||||
async with semaphore:
|
||||
status, result = await analyze_one_stock(i, total, code, name)
|
||||
all_results.append((status, code, name, result))
|
||||
try:
|
||||
async with semaphore:
|
||||
status, result = await analyze_one_stock(i, total, code, name)
|
||||
await queue.put((status, code, name, result))
|
||||
except Exception as exc:
|
||||
logger.error("❌ analyze_one unexpected error %s: %s", code, exc)
|
||||
await queue.put(("error", code, name, None))
|
||||
|
||||
# 저장기를 백그라운드로 먼저 시작
|
||||
saver_task = asyncio.create_task(_streaming_saver(queue, analysis_date))
|
||||
|
||||
# 분석 태스크 전체 실행
|
||||
tasks = [analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Step 4: 통계 집계
|
||||
ok_items = [(code, name, r) for status, code, name, r in all_results if status == "ok" and r]
|
||||
ok_count = len(ok_items)
|
||||
no_chart = sum(1 for s, _, _, _ in all_results if s == "no_chart")
|
||||
price_flt = sum(1 for s, _, _, _ in all_results if s == "price_filter")
|
||||
err_count = sum(1 for s, _, _, _ in all_results if s in ("error",) or isinstance(s, Exception))
|
||||
# sentinel 전송 → 저장기 종료
|
||||
await queue.put(None)
|
||||
stats = await saver_task
|
||||
|
||||
# Step 5: BULK_SAVE_CHUNK 단위 일괄 DB 저장
|
||||
save_count = 0
|
||||
for chunk_start in range(0, len(ok_items), BULK_SAVE_CHUNK):
|
||||
chunk = ok_items[chunk_start: chunk_start + BULK_SAVE_CHUNK]
|
||||
saved = await bulk_save_results(chunk, analysis_date)
|
||||
save_count += saved
|
||||
logger.info("💾 DB 저장: %d/%d건 완료", save_count, ok_count)
|
||||
ok_count = stats.get("ok", 0)
|
||||
no_chart = stats.get("no_chart", 0)
|
||||
price_flt = stats.get("price_filter", 0)
|
||||
err_count = stats.get("error", 0)
|
||||
save_count = stats.get("saved", 0)
|
||||
|
||||
# Step 6: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
|
||||
# Step 5: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
|
||||
scanner_tickers = await run_db_scanner(analysis_date)
|
||||
|
||||
# Step 7: 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||
# Step 6: 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||
try:
|
||||
from app.services.watchlist import initialize_watchlist
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -423,7 +306,7 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
|
||||
# 결과 요약
|
||||
duration = (datetime.now(KST) - start_time).total_seconds()
|
||||
stats = korea_investment_api.get_monitor_stats()
|
||||
api_stats = korea_investment_api.get_monitor_stats()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Batch analysis completed!")
|
||||
@@ -435,8 +318,8 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
logger.info("🔍 DB 스캐너 신호: %d개", len(scanner_tickers))
|
||||
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
|
||||
logger.info("📡 API 통계 — 호출:%d 오류:%d(%.1f%%) 평균:%.2f초",
|
||||
stats['total_calls'], stats['total_errors'],
|
||||
stats['error_rate'] * 100, stats['avg_duration'])
|
||||
api_stats['total_calls'], api_stats['total_errors'],
|
||||
api_stats['error_rate'] * 100, api_stats['avg_duration'])
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
|
||||
@@ -21,21 +21,20 @@ KST = pytz.timezone('Asia/Seoul')
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import select, delete, update, func
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import logging
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||
from app.core.market_schedule import is_market_open
|
||||
from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch
|
||||
from app.services.analysis_db_service import upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||||
from app.services.watchlist import (
|
||||
WATCHLIST_MAX_SIZE,
|
||||
WATCHLIST_ENTRY_SCORE,
|
||||
WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
from batch_daily_analysis import run_db_scanner
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -49,25 +48,6 @@ BATCH_CONCURRENCY = 5
|
||||
# 워치리스트 외부 후보 재분석 상한 (10분 배치 내에 처리 가능한 수준)
|
||||
OUTSIDE_RESCORE_LIMIT = 30
|
||||
|
||||
# DB 연결
|
||||
database_url = settings.DATABASE_URL
|
||||
if database_url.startswith("postgresql://"):
|
||||
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
engine = create_async_engine(
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_active_watchlist() -> list[tuple[str, str]]:
|
||||
"""현재 활성 분석종목 목록 조회."""
|
||||
@@ -84,28 +64,93 @@ async def get_active_watchlist() -> list[tuple[str, str]]:
|
||||
"아침 배치(batch_daily_analysis.py)가 먼저 실행되었는지 확인하세요."
|
||||
)
|
||||
else:
|
||||
logger.info(f"📋 분석종목 {len(rows)}개 대상")
|
||||
logger.info("📋 분석종목 %d개 대상", len(rows))
|
||||
return [(row.stock_code, row.stock_name) for row in rows]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# analyze_update_and_manage 분리: 분석 / 저장 / 관리
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _save_analysis_and_history(
|
||||
session,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
result,
|
||||
analysis_date: date,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
"""stock_analysis UPSERT + watchlist_history 시계열 기록.
|
||||
|
||||
DELETE+INSERT race condition 없이 원자적으로 갱신.
|
||||
"""
|
||||
# 1. stock_analysis UPSERT (race-condition-free)
|
||||
await upsert_analysis(session, stock_code, stock_name, result, analysis_date)
|
||||
|
||||
# 2. watchlist_history 시계열 기록
|
||||
session.add(WatchlistHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
recorded_at=now,
|
||||
score=result.score,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
rsi=result.rsi,
|
||||
signals=result.signals,
|
||||
))
|
||||
|
||||
|
||||
async def _manage_watchlist_membership(
|
||||
session,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
score: int | None,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""score 기준 미달 시 watchlist에서 제거.
|
||||
|
||||
Returns: True if removed, False if retained.
|
||||
"""
|
||||
if score is not None and score < WATCHLIST_EXIT_SCORE:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(
|
||||
WatchlistStock.stock_code == stock_code,
|
||||
WatchlistStock.is_active == True,
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
removed_at=now,
|
||||
removed_reason="score_drop",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"🚫 %s (%s) 분석종목 제거: score=%s < %s",
|
||||
stock_code, stock_name, score, WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def analyze_update_and_manage(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
analysis_date: date,
|
||||
) -> tuple[bool, int | None]:
|
||||
) -> tuple[bool, int | None, bool]:
|
||||
"""종목 재분석 → stock_analysis 갱신 → watchlist_history 저장 → watchlist 관리.
|
||||
|
||||
Returns: (success, score)
|
||||
Returns: (success, score, was_removed)
|
||||
was_removed: _manage_watchlist_membership이 실제로 비활성화 처리했으면 True
|
||||
"""
|
||||
try:
|
||||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||||
if not result:
|
||||
logger.info(f"No result for {stock_code} (chart/price issue)")
|
||||
return False, None
|
||||
logger.info("No result for %s (chart/price issue)", stock_code)
|
||||
return False, None, False
|
||||
|
||||
# 5,000원 이하 저가주 → watchlist에서도 제거
|
||||
if result.current_price and result.current_price < 5000:
|
||||
logger.debug(f"Skip {stock_code}: price={result.current_price:.0f}원 < 5,000원")
|
||||
if not passes_price_filter(result):
|
||||
logger.debug("Skip %s: price=%.0f원 < 5,000원", stock_code, result.current_price)
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
@@ -120,83 +165,26 @@ async def analyze_update_and_manage(
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return False, None
|
||||
return False, None, True # 가격필터 제거도 was_removed=True
|
||||
|
||||
now = datetime.now(KST)
|
||||
score = result.score
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. stock_analysis 갱신 (DELETE + INSERT)
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == stock_code,
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
await _save_analysis_and_history(
|
||||
session, stock_code, stock_name, result, analysis_date, now
|
||||
)
|
||||
was_removed = await _manage_watchlist_membership(
|
||||
session, stock_code, stock_name, score, now
|
||||
)
|
||||
session.add(StockAnalysis(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
volume=result.volume,
|
||||
rsi=result.rsi,
|
||||
ma5=result.ma5,
|
||||
ma20=result.ma20,
|
||||
ma60=result.ma60,
|
||||
stochastic_k=result.stochastic_k,
|
||||
stochastic_d=result.stochastic_d,
|
||||
bollinger_upper=result.bollinger_upper,
|
||||
bollinger_lower=result.bollinger_lower,
|
||||
bollinger_middle=result.bollinger_middle,
|
||||
signals=result.signals,
|
||||
score=score,
|
||||
ml_score=result.ml_score,
|
||||
ml_confidence=result.ml_confidence,
|
||||
ml_signal=result.ml_signal,
|
||||
atr_14=result.atr_14,
|
||||
adx_14=result.adx_14,
|
||||
))
|
||||
|
||||
# 2. watchlist_history 시계열 기록
|
||||
session.add(WatchlistHistory(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
recorded_at=now,
|
||||
score=score,
|
||||
current_price=result.current_price,
|
||||
change_rate=result.change_rate,
|
||||
rsi=result.rsi,
|
||||
signals=result.signals,
|
||||
))
|
||||
|
||||
# 3. score가 기준 이하면 분석종목에서 제거
|
||||
if score is not None and score < WATCHLIST_EXIT_SCORE:
|
||||
await session.execute(
|
||||
update(WatchlistStock)
|
||||
.where(
|
||||
WatchlistStock.stock_code == stock_code,
|
||||
WatchlistStock.is_active == True,
|
||||
)
|
||||
.values(
|
||||
is_active=False,
|
||||
removed_at=now,
|
||||
removed_reason="score_drop",
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
f"🚫 {stock_code} ({stock_name}) 분석종목 제거: "
|
||||
f"score={score} < {WATCHLIST_EXIT_SCORE}"
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ {stock_code} ({stock_name}): score={score}")
|
||||
return True, score
|
||||
logger.info("✅ %s (%s): score=%s", stock_code, stock_name, score)
|
||||
return True, score, was_removed
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed {stock_code}: {e}")
|
||||
return False, None
|
||||
logger.error("❌ Failed %s: %s", stock_code, e)
|
||||
return False, None, False
|
||||
|
||||
|
||||
async def rescore_outside_candidates(
|
||||
@@ -232,7 +220,7 @@ async def rescore_outside_candidates(
|
||||
return 0
|
||||
|
||||
total = len(candidates)
|
||||
logger.info(f"🔍 워치리스트 외부 후보 {total}개 재분석 시작")
|
||||
logger.info("🔍 워치리스트 외부 후보 %d개 재분석 시작", total)
|
||||
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
|
||||
@@ -242,48 +230,19 @@ async def rescore_outside_candidates(
|
||||
res = await analyze_stock_for_batch(code, name)
|
||||
if not res:
|
||||
return False
|
||||
# 저가주 필터 (CLAUDE.md 섹션 6.4)
|
||||
if res.current_price and res.current_price < 5000:
|
||||
logger.debug(f"Skip {code}: price={res.current_price:.0f}원 < 5,000원")
|
||||
# 저가주 필터 (CLAUDE.md 5절 가격 필터)
|
||||
if not passes_price_filter(res):
|
||||
logger.debug("Skip %s: price=%.0f원 < 5,000원", code, res.current_price)
|
||||
return False
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code == code,
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
)
|
||||
session.add(StockAnalysis(
|
||||
stock_code=code,
|
||||
stock_name=name,
|
||||
analysis_date=analysis_date,
|
||||
current_price=res.current_price,
|
||||
change_rate=res.change_rate,
|
||||
volume=res.volume,
|
||||
rsi=res.rsi,
|
||||
ma5=res.ma5,
|
||||
ma20=res.ma20,
|
||||
ma60=res.ma60,
|
||||
stochastic_k=res.stochastic_k,
|
||||
stochastic_d=res.stochastic_d,
|
||||
bollinger_upper=res.bollinger_upper,
|
||||
bollinger_lower=res.bollinger_lower,
|
||||
bollinger_middle=res.bollinger_middle,
|
||||
signals=res.signals,
|
||||
score=res.score,
|
||||
ml_score=res.ml_score,
|
||||
ml_confidence=res.ml_confidence,
|
||||
ml_signal=res.ml_signal,
|
||||
atr_14=res.atr_14,
|
||||
adx_14=res.adx_14,
|
||||
))
|
||||
await upsert_analysis(session, code, name, res, analysis_date)
|
||||
await session.commit()
|
||||
|
||||
logger.debug(f" [{i}/{total}] {code} ({name}): score={res.score}")
|
||||
logger.debug(" [%d/%d] %s (%s): score=%s", i, total, code, name, res.score)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 외부 후보 재분석 실패 {code}: {e}")
|
||||
logger.error("❌ 외부 후보 재분석 실패 %s: %s", code, e)
|
||||
return False
|
||||
|
||||
tasks = [
|
||||
@@ -292,7 +251,7 @@ async def rescore_outside_candidates(
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
success = sum(1 for r in results if r is True)
|
||||
logger.info(f"✅ 외부 후보 재분석 완료: {success}/{total}개 갱신")
|
||||
logger.info("✅ 외부 후보 재분석 완료: %d/%d개 갱신", success, total)
|
||||
return success
|
||||
|
||||
|
||||
@@ -316,7 +275,7 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
||||
|
||||
slots = WATCHLIST_MAX_SIZE - active_count
|
||||
if slots <= 0:
|
||||
logger.debug(f"Watchlist 최대치 도달 ({active_count}/{WATCHLIST_MAX_SIZE})")
|
||||
logger.debug("Watchlist 최대치 도달 (%d/%d)", active_count, WATCHLIST_MAX_SIZE)
|
||||
return 0
|
||||
|
||||
# stock_analysis에서 고점수 종목 중 현재 watchlist에 없는 것 조회
|
||||
@@ -353,8 +312,8 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"➕ 분석종목 {len(candidates)}개 신규 추가 "
|
||||
f"(score ≥ {WATCHLIST_ENTRY_SCORE}, 현재 {active_count + len(candidates)}개)"
|
||||
"➕ 분석종목 %d개 신규 추가 (score ≥ %d, 현재 %d개)",
|
||||
len(candidates), WATCHLIST_ENTRY_SCORE, active_count + len(candidates),
|
||||
)
|
||||
return len(candidates)
|
||||
|
||||
@@ -369,8 +328,8 @@ async def main():
|
||||
start_time = datetime.now(KST)
|
||||
analysis_date = datetime.now(KST).date()
|
||||
logger.info(
|
||||
f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — "
|
||||
f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})"
|
||||
"⚡ [Intraday] %s 시작 — 분석종목 재분석 (진입≥%d, 제거<%d)",
|
||||
start_time.strftime("%H:%M:%S"), WATCHLIST_ENTRY_SCORE, WATCHLIST_EXIT_SCORE,
|
||||
)
|
||||
|
||||
# 활성 분석종목 조회
|
||||
@@ -385,9 +344,9 @@ async def main():
|
||||
# 병렬 재분석
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
|
||||
async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None]:
|
||||
async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None, bool]:
|
||||
async with semaphore:
|
||||
logger.info(f"[{i}/{total}] {code} ({name})")
|
||||
logger.info("[%d/%d] %s (%s)", i, total, code, name)
|
||||
return await analyze_update_and_manage(code, name, analysis_date)
|
||||
|
||||
tasks = [
|
||||
@@ -396,15 +355,11 @@ async def main():
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# removed 카운터 수정: analyze_update_and_manage는 제거 시에도 True 반환하므로
|
||||
# success(=분석 성공)에 removed가 포함됨 → retained = success - removed 로 표시
|
||||
analyzed_ok = sum(1 for r in results if isinstance(r, tuple) and r[0] is True)
|
||||
removed = sum(
|
||||
1 for r in results
|
||||
if isinstance(r, tuple) and r[0] is True
|
||||
and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE
|
||||
removed = sum(1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[2] is True)
|
||||
retained = analyzed_ok - sum(
|
||||
1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[0] is True and r[2] is True
|
||||
)
|
||||
retained = analyzed_ok - removed # 워치리스트에 실제 유지된 종목 수
|
||||
fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False)
|
||||
error = sum(1 for r in results if isinstance(r, Exception))
|
||||
|
||||
@@ -419,9 +374,8 @@ async def main():
|
||||
now_kst = datetime.now(KST)
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 현재 활성 종목 수 확인
|
||||
from sqlalchemy import func as sa_func
|
||||
count_result = await session.execute(
|
||||
select(sa_func.count())
|
||||
select(func.count())
|
||||
.select_from(WatchlistStock)
|
||||
.where(WatchlistStock.is_active == True)
|
||||
)
|
||||
@@ -453,7 +407,7 @@ async def main():
|
||||
await session.commit()
|
||||
|
||||
if added_scanner:
|
||||
logger.info(f"📡 장중 스캐너 신규 추가: {added_scanner}개 (RSI≤30+BB하단)")
|
||||
logger.info("📡 장중 스캐너 신규 추가: %d개 (RSI≤30+BB하단)", added_scanner)
|
||||
current_codes |= scanner_new # refresh_watchlist 중복 방지용 갱신
|
||||
|
||||
# 고점수 신규 종목 추가 (스캐너 추가분 제외)
|
||||
@@ -461,12 +415,11 @@ async def main():
|
||||
|
||||
duration = (datetime.now(KST) - start_time).total_seconds()
|
||||
logger.info(
|
||||
f"✅ [Intraday] 완료: {retained}/{total} 유지, "
|
||||
f"{removed} 제거, +{added} 신규(score), +{added_scanner} 신규(스캐너), "
|
||||
f"{fail} 실패, {error} 오류 — {duration:.1f}초"
|
||||
"✅ [Intraday] 완료: %d/%d 유지, %d 제거, +%d 신규(score), +%d 신규(스캐너), "
|
||||
"%d 실패, %d 오류 — %.1f초",
|
||||
retained, total, removed, added, added_scanner, fail, error, duration,
|
||||
)
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
66
backend/migrations/fix_analysis_date_type.sql
Normal file
66
backend/migrations/fix_analysis_date_type.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- Fix: stock_analysis.analysis_date 컬럼 타입 TIMESTAMP → DATE
|
||||
-- 배경:
|
||||
-- analysis_date가 TIMESTAMP이면 datetime.now() 저장 시 매 실행마다 서로 다른 타임스탬프가
|
||||
-- 생성되어 unique constraint가 무의미해지고 중복 행이 무한 누적된다.
|
||||
-- DATE 타입으로 변경하면 동일 (stock_code, 날짜) 쌍의 ON CONFLICT가 올바르게 동작한다.
|
||||
--
|
||||
-- 실행: psql -d autostock_db -f migrations/fix_analysis_date_type.sql
|
||||
-- 주의: 실행 전 DB 백업 권장
|
||||
-- 배치 스크립트 중단 후 실행할 것
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
col_type TEXT;
|
||||
BEGIN
|
||||
-- 현재 analysis_date 컬럼 타입 확인
|
||||
SELECT data_type INTO col_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'stock_analysis'
|
||||
AND column_name = 'analysis_date';
|
||||
|
||||
IF col_type IS NULL THEN
|
||||
RAISE EXCEPTION 'stock_analysis 테이블 또는 analysis_date 컬럼이 존재하지 않습니다.';
|
||||
END IF;
|
||||
|
||||
IF col_type = 'date' THEN
|
||||
RAISE NOTICE 'analysis_date 컬럼이 이미 DATE 타입입니다. 마이그레이션 불필요.';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF col_type NOT IN ('timestamp without time zone', 'timestamp with time zone') THEN
|
||||
RAISE EXCEPTION '예상치 못한 컬럼 타입: %. 수동 확인이 필요합니다.', col_type;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE '현재 타입: %. TIMESTAMP → DATE 변환을 시작합니다.', col_type;
|
||||
|
||||
-- Step 1: 기존 unique constraint 제거 (컬럼 타입 변경 전 반드시 삭제)
|
||||
ALTER TABLE stock_analysis DROP CONSTRAINT IF EXISTS uq_stock_analysis_code_date;
|
||||
RAISE NOTICE 'unique constraint uq_stock_analysis_code_date 삭제 완료';
|
||||
|
||||
-- Step 2: 동일 (stock_code, analysis_date::date) 쌍의 중복 행 제거
|
||||
-- TIMESTAMP 시절에 datetime.now()로 저장된 경우 같은 날짜에 여러 행이 존재할 수 있음
|
||||
-- → 가장 최근 id만 남기고 나머지 삭제
|
||||
DELETE FROM stock_analysis
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (stock_code, analysis_date::date) id
|
||||
FROM stock_analysis
|
||||
ORDER BY stock_code, analysis_date::date, id DESC
|
||||
);
|
||||
RAISE NOTICE '중복 행 정리 완료 (남은 행 수: %)', (SELECT COUNT(*) FROM stock_analysis);
|
||||
|
||||
-- Step 3: 컬럼 타입 변환 (TIMESTAMP → DATE)
|
||||
ALTER TABLE stock_analysis
|
||||
ALTER COLUMN analysis_date TYPE DATE
|
||||
USING analysis_date::date;
|
||||
RAISE NOTICE 'analysis_date 컬럼 타입 변환 완료: % → DATE', col_type;
|
||||
|
||||
-- Step 4: unique constraint 재생성
|
||||
ALTER TABLE stock_analysis
|
||||
ADD CONSTRAINT uq_stock_analysis_code_date
|
||||
UNIQUE (stock_code, analysis_date);
|
||||
RAISE NOTICE 'unique constraint uq_stock_analysis_code_date 재생성 완료';
|
||||
|
||||
-- Step 5: 인덱스 확인 (기존 인덱스는 타입 변경 시 자동 갱신됨)
|
||||
RAISE NOTICE '마이그레이션 완료: analysis_date TIMESTAMP → DATE';
|
||||
END
|
||||
$$;
|
||||
@@ -133,7 +133,7 @@ services:
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- SCHEDULE=0 17 * * * # UTC 17:00 = KST 02:00
|
||||
- SCHEDULE=0 8,17 * * *
|
||||
- BACKUP_KEEP_DAYS=7
|
||||
- BACKUP_KEEP_WEEKS=4
|
||||
- BACKUP_KEEP_MONTHS=3
|
||||
|
||||
@@ -180,5 +180,9 @@ CREATE INDEX IF NOT EXISTS idx_stock_analysis_stock_code ON stock_analysis(stock
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_analysis_analysis_date ON stock_analysis(analysis_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_analysis_score_desc ON stock_analysis(score DESC);
|
||||
|
||||
-- UPSERT (ON CONFLICT) 동작을 위한 unique constraint (batch_daily/intraday UPSERT 필수)
|
||||
ALTER TABLE stock_analysis
|
||||
ADD CONSTRAINT uq_stock_analysis_code_date UNIQUE (stock_code, analysis_date);
|
||||
|
||||
CREATE TRIGGER update_stock_analysis_updated_at BEFORE UPDATE ON stock_analysis
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 });
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'Asia/Seoul' });
|
||||
};
|
||||
|
||||
// 마켓 운영 시간 체크 (한국장 + 미국장)
|
||||
@@ -70,10 +70,11 @@ const Dashboard = () => {
|
||||
const { data: mcpStatus } = useQuery({
|
||||
queryKey: ['mcp-status'],
|
||||
queryFn: async () => (await api.get('/mcp/status')).data,
|
||||
refetchInterval: isMarketOpen ? 5000 : false,
|
||||
refetchInterval: isMarketOpen ? 30000 : false, // 5초 → 30초 (MCP 상태는 빈번한 폴링 불필요)
|
||||
staleTime: 20000,
|
||||
})
|
||||
|
||||
const { data: ruleBasedRecommendations } = useQuery({
|
||||
const { data: ruleBasedRecommendations, refetch: refetchRecommendations, isError: isRecommendationsError } = useQuery({
|
||||
queryKey: ['rule-based-recommendations'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/recommendations/rule-based', {
|
||||
@@ -95,8 +96,9 @@ const Dashboard = () => {
|
||||
|
||||
return data;
|
||||
},
|
||||
refetchInterval: isMarketOpen ? 600000 : false, // 10분마다 배치 실행
|
||||
// 장외에도 초기 로드는 실행 (DB 조회는 빠름)
|
||||
refetchInterval: isMarketOpen ? 120000 : 300000, // 장중 2분, 장외 5분
|
||||
staleTime: isMarketOpen ? 110000 : 290000, // refetchInterval보다 약간 짧게 → 재진입 시 즉시 표시
|
||||
refetchOnWindowFocus: false, // 탭 전환 시 불필요한 재요청 방지 (자동매매 중복 트리거 차단)
|
||||
})
|
||||
|
||||
const { data: chartData, isLoading: chartLoading } = useQuery({
|
||||
@@ -201,13 +203,11 @@ const Dashboard = () => {
|
||||
}
|
||||
}, [wsMessages, queryClient])
|
||||
|
||||
// 보유종목 + 분석종목 전체 코드 수집 (현재가 일괄 조회용)
|
||||
// 보유종목 코드만 수집 (현재가 일괄 실시간 조회용)
|
||||
// 분석종목은 stock_analysis.current_price (DB값)를 폴백으로 사용하므로 실시간 조회 불필요
|
||||
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])
|
||||
return (portfolio || []).map((s: any) => s.stock_code)
|
||||
}, [portfolio])
|
||||
|
||||
// 현재가 일괄 실시간 조회 (보유종목·분석종목 동기화)
|
||||
const { data: livePrices } = useQuery({
|
||||
@@ -698,10 +698,23 @@ const Dashboard = () => {
|
||||
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
|
||||
{ruleBasedRecommendations?.analyzed_stocks || 0}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => refetchRecommendations()}
|
||||
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
|
||||
title="분석 종목 새로고침"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-purple-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||
{ruleBasedRecommendations === undefined ? (
|
||||
{isRecommendationsError ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-red-500 text-xs p-4 text-center">
|
||||
<p>⚠️ API 오류</p>
|
||||
<p className="text-[10px] text-gray-500 mt-1">백엔드 로그 확인 필요</p>
|
||||
<button onClick={() => refetchRecommendations()} className="mt-2 px-2 py-1 bg-red-500/20 text-red-400 rounded text-[10px]">재시도</button>
|
||||
</div>
|
||||
) : ruleBasedRecommendations === undefined ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-gray-500 text-xs p-4">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
|
||||
<p>분석 종목 로딩 중...</p>
|
||||
@@ -790,8 +803,8 @@ const Dashboard = () => {
|
||||
{journals && journals.length > 0 ? (
|
||||
journals.map((journal: any) => {
|
||||
const journalDate = new Date(journal.journal_date || journal.created_at);
|
||||
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' });
|
||||
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul' });
|
||||
const timeStr = journalDate.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Seoul' });
|
||||
const isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive';
|
||||
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative';
|
||||
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : [];
|
||||
|
||||
Reference in New Issue
Block a user