구조적인 부분 수정
This commit is contained in:
@@ -84,6 +84,12 @@ _orb_cache: Dict[str, OpeningRange] = {}
|
|||||||
# 일일 실현 손익 (원)
|
# 일일 실현 손익 (원)
|
||||||
_daily_pnl: float = 0.0
|
_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
|
_last_reset_date: Optional[date] = None
|
||||||
|
|
||||||
@@ -93,11 +99,13 @@ _trade_lock = asyncio.Lock()
|
|||||||
|
|
||||||
def _reset_if_new_day() -> None:
|
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()
|
today = _today_kst()
|
||||||
if _last_reset_date != today:
|
if _last_reset_date != today:
|
||||||
_daily_pnl = 0.0
|
_daily_pnl = 0.0
|
||||||
_orb_cache = {}
|
_orb_cache = {}
|
||||||
|
_last_total_assets = 0.0
|
||||||
|
_closed_today = set()
|
||||||
_last_reset_date = today
|
_last_reset_date = today
|
||||||
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
|
logger.info(f"📅 데이트레이딩 상태 일일 초기화: {today}")
|
||||||
|
|
||||||
@@ -169,7 +177,7 @@ async def _place_buy_order(
|
|||||||
async def _place_sell_order(
|
async def _place_sell_order(
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
quantity: int,
|
quantity: int,
|
||||||
price: int = 0, # 0 = 시장가
|
price: int = 0, # 0 = 시장가 (None으로 변환)
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""매도 주문 실행"""
|
"""매도 주문 실행"""
|
||||||
try:
|
try:
|
||||||
@@ -177,7 +185,7 @@ async def _place_sell_order(
|
|||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type="SELL",
|
order_type="SELL",
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=price,
|
price=price if price > 0 else None, # 0이면 None → 시장가(01)
|
||||||
)
|
)
|
||||||
return result.get("rt_cd") == "0"
|
return result.get("rt_cd") == "0"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -194,18 +202,24 @@ async def _record_trade(
|
|||||||
quantity: int,
|
quantity: int,
|
||||||
reason: str,
|
reason: str,
|
||||||
profit_loss: float = 0.0,
|
profit_loss: float = 0.0,
|
||||||
|
order_price: Optional[float] = None, # 주문가 (슬리피지 분석용)
|
||||||
) -> None:
|
) -> None:
|
||||||
"""TradeHistory DB 기록"""
|
"""TradeHistory DB 기록"""
|
||||||
try:
|
try:
|
||||||
|
total = price * quantity
|
||||||
|
slippage = (price - order_price) if order_price is not None else None
|
||||||
trade = TradeHistory(
|
trade = TradeHistory(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
stock_name=stock_name,
|
stock_name=stock_name,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
|
order_price=order_price,
|
||||||
|
order_quantity=quantity,
|
||||||
executed_price=price,
|
executed_price=price,
|
||||||
executed_quantity=quantity,
|
executed_quantity=quantity,
|
||||||
total_amount=price * quantity,
|
total_amount=total,
|
||||||
profit_loss=profit_loss,
|
net_profit_loss=profit_loss, # ← net_profit_loss (profit_loss 컬럼 없음)
|
||||||
profit_loss_rate=(profit_loss / (price * quantity) * 100) if price * quantity > 0 else 0,
|
profit_loss_rate=(profit_loss / total * 100) if total > 0 else 0,
|
||||||
|
slippage=slippage,
|
||||||
status="COMPLETED",
|
status="COMPLETED",
|
||||||
entry_reason=reason if order_type == "BUY" else None,
|
entry_reason=reason if order_type == "BUY" else None,
|
||||||
exit_reason=reason if order_type == "SELL" 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)
|
result["orb_count"] = len(_orb_cache)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ── 3. 매매 가능 구간 (09:30~15:00) ──
|
# ── 3. 매매 가능 구간 ──
|
||||||
if not is_day_trade_entry_window():
|
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"
|
result["phase"] = "outside_window"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -271,11 +288,18 @@ async def run_day_trading_cycle() -> Dict[str, Any]:
|
|||||||
exits = await _check_exits(candle_cache=_cycle_candle_cache)
|
exits = await _check_exits(candle_cache=_cycle_candle_cache)
|
||||||
result["exits"] = exits
|
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:
|
if len(_positions) < MAX_POSITIONS and settings.AUTO_TRADE_ENABLED:
|
||||||
portfolio = await _get_portfolio_summary()
|
portfolio = await _get_portfolio_summary()
|
||||||
total_assets = portfolio["total_assets"]
|
total_assets = portfolio["total_assets"]
|
||||||
available_cash = portfolio["available_cash"]
|
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):
|
if not is_daily_loss_exceeded(total_assets, _daily_pnl):
|
||||||
current_day_trade_amount = sum(
|
current_day_trade_amount = sum(
|
||||||
@@ -379,8 +403,10 @@ async def _check_exits(
|
|||||||
ok = await _place_sell_order(code, quantity, price=0) # 시장가
|
ok = await _place_sell_order(code, quantity, price=0) # 시장가
|
||||||
if ok:
|
if ok:
|
||||||
pnl = (current_price - pos["entry_price"]) * quantity
|
pnl = (current_price - pos["entry_price"]) * quantity
|
||||||
_daily_pnl += pnl
|
async with _trade_lock:
|
||||||
del _positions[code]
|
_daily_pnl += pnl
|
||||||
|
_positions.pop(code, None)
|
||||||
|
_closed_today.add(code) # 당일 재진입 차단
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
await _record_trade(
|
await _record_trade(
|
||||||
@@ -432,6 +458,8 @@ async def _scan_signals(
|
|||||||
for code, name in list(_watchlist.items()):
|
for code, name in list(_watchlist.items()):
|
||||||
if code in _positions:
|
if code in _positions:
|
||||||
continue
|
continue
|
||||||
|
if code in _closed_today: # 당일 청산 종목 재진입 차단
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
if candle_cache is not None and code in candle_cache:
|
if candle_cache is not None and code in candle_cache:
|
||||||
candles_1m = candle_cache[code]
|
candles_1m = candle_cache[code]
|
||||||
@@ -494,6 +522,9 @@ async def _execute_entry(signal: DayTradeSignal, quantity: int) -> Optional[Dict
|
|||||||
return None
|
return None
|
||||||
if signal.stock_code in _positions:
|
if signal.stock_code in _positions:
|
||||||
return None
|
return None
|
||||||
|
if signal.stock_code in _closed_today: # 당일 청산 종목 재진입 최종 방어
|
||||||
|
logger.info(f"⛔ 당일 청산 종목 재진입 차단: {signal.stock_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
order_no = await _place_buy_order(
|
order_no = await _place_buy_order(
|
||||||
signal.stock_code, quantity, int(signal.entry_price)
|
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(
|
await _record_trade(
|
||||||
db, signal.stock_code, signal.stock_name, "BUY",
|
db, signal.stock_code, signal.stock_name, "BUY",
|
||||||
signal.entry_price, quantity, signal.reason,
|
signal.entry_price, quantity, signal.reason,
|
||||||
|
order_price=float(int(signal.entry_price)), # 주문가 = 지정가
|
||||||
)
|
)
|
||||||
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
|
# ActiveTrade 저장 — 재시작 시 포지션 복구를 위해
|
||||||
active = ActiveTrade(
|
active = ActiveTrade(
|
||||||
@@ -563,8 +595,10 @@ async def _execute_eod_flat() -> None:
|
|||||||
ok = await _place_sell_order(code, pos["quantity"], price=0)
|
ok = await _place_sell_order(code, pos["quantity"], price=0)
|
||||||
if ok:
|
if ok:
|
||||||
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
|
pnl = (current_price - pos["entry_price"]) * pos["quantity"]
|
||||||
_daily_pnl += pnl
|
async with _trade_lock:
|
||||||
del _positions[code]
|
_daily_pnl += pnl
|
||||||
|
_positions.pop(code, None)
|
||||||
|
_closed_today.add(code) # 당일 재진입 차단
|
||||||
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
|
logger.info(f"🔔 EOD 강제 청산 [{code}]: PNL={pnl:+,.0f}원")
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
@@ -595,10 +629,15 @@ async def restore_positions_from_db() -> None:
|
|||||||
|
|
||||||
strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로
|
strategy_name='day_trade', status='active'인 당일 ActiveTrade 레코드로
|
||||||
인메모리 _positions를 재구성한다.
|
인메모리 _positions를 재구성한다.
|
||||||
|
|
||||||
|
created_at은 UTC naive datetime으로 저장되므로 날짜 비교는 UTC 기준으로 수행.
|
||||||
|
한국 장중(09:00~15:30 KST = 00:00~06:30 UTC)은 UTC/KST 날짜가 항상 일치하므로
|
||||||
|
UTC 기준으로 비교해도 동일한 결과를 얻는다.
|
||||||
"""
|
"""
|
||||||
if _positions:
|
if _positions:
|
||||||
return # 이미 포지션 있으면 스킵
|
return # 이미 포지션 있으면 스킵
|
||||||
today = _today_kst()
|
from datetime import timezone
|
||||||
|
today_utc = datetime.now(timezone.utc).date() # UTC 기준 오늘 날짜 (created_at과 동일 기준)
|
||||||
try:
|
try:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -609,9 +648,16 @@ async def restore_positions_from_db() -> None:
|
|||||||
)
|
)
|
||||||
rows = result.scalars().all()
|
rows = result.scalars().all()
|
||||||
for row in rows:
|
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
|
continue
|
||||||
entry_price = float(row.entry_price)
|
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] = {
|
_positions[row.stock_code] = {
|
||||||
"stock_name": row.stock_name or row.stock_code,
|
"stock_name": row.stock_name or row.stock_code,
|
||||||
"strategy": row.entry_reason or "unknown",
|
"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,
|
"target_price": float(row.take_profit) if row.take_profit else entry_price * 1.03,
|
||||||
"quantity": row.quantity,
|
"quantity": row.quantity,
|
||||||
"highest_price": float(row.highest_since_entry or row.entry_price),
|
"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 "",
|
"reason": row.entry_reason or "",
|
||||||
}
|
}
|
||||||
if _positions:
|
if _positions:
|
||||||
@@ -793,7 +839,7 @@ async def get_positions():
|
|||||||
"count": len(result),
|
"count": len(result),
|
||||||
"daily_pnl": round(_daily_pnl, 0),
|
"daily_pnl": round(_daily_pnl, 0),
|
||||||
"is_daily_loss_exceeded": is_daily_loss_exceeded(
|
"is_daily_loss_exceeded": is_daily_loss_exceeded(
|
||||||
sum(p["entry_price"] * p["quantity"] for p in _positions.values()),
|
_last_total_assets, # 사이클 캐시 사용 (포지션 가치가 아닌 총자산)
|
||||||
_daily_pnl,
|
_daily_pnl,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
import logging
|
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.core.database import get_db
|
||||||
from app.models.models import TradeJournal
|
from app.models.models import TradeJournal
|
||||||
|
|
||||||
@@ -42,12 +49,12 @@ async def list_journals(
|
|||||||
"trade_id": j.trade_id,
|
"trade_id": j.trade_id,
|
||||||
"stock_code": j.stock_code,
|
"stock_code": j.stock_code,
|
||||||
"stock_name": j.stock_name,
|
"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,
|
"content": j.content,
|
||||||
"strategy_name": j.strategy_name,
|
"strategy_name": j.strategy_name,
|
||||||
"mood": j.mood,
|
"mood": j.mood,
|
||||||
"tags": j.tags,
|
"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
|
for j in journals
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import random
|
import random
|
||||||
import logging
|
import logging
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
_KST = pytz.timezone("Asia/Seoul")
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||||
from typing import List, Dict
|
from typing import List, Dict
|
||||||
@@ -693,7 +696,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
|||||||
"recommendation": recommendation,
|
"recommendation": recommendation,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"trend": trend,
|
"trend": trend,
|
||||||
"analyzed_at": datetime.now().isoformat(),
|
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async def get_mcp_status():
|
|||||||
"""MCP 서버 상태 확인"""
|
"""MCP 서버 상태 확인"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
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:
|
if response.status_code == 200:
|
||||||
health_data = response.json()
|
health_data = response.json()
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
import pytz
|
import pytz
|
||||||
|
import time
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
from app.services.korea_investment_api import korea_investment_api
|
from app.services.korea_investment_api import korea_investment_api
|
||||||
from app.core.database import get_db, AsyncSessionLocal
|
from app.core.database import get_db, AsyncSessionLocal
|
||||||
@@ -27,6 +29,58 @@ from app.services.loss_tracker import loss_tracker
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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')
|
KST = pytz.timezone('Asia/Seoul')
|
||||||
|
|
||||||
@@ -170,7 +224,7 @@ def analyze_custom_signals(chart_data):
|
|||||||
close = closes[-1]
|
close = closes[-1]
|
||||||
|
|
||||||
# 🔥 이격도 계산 (추격 매수 방지) - 더 엄격하게
|
# 🔥 이격도 계산 (추격 매수 방지) - 더 엄격하게
|
||||||
disparity = (close / ma20) * 100
|
disparity = (close / ma20) * 100 if ma20 else 100
|
||||||
|
|
||||||
# 🚫 급등 종목 필터: 최근 3일 상승률 체크
|
# 🚫 급등 종목 필터: 최근 3일 상승률 체크
|
||||||
if len(closes) >= 4:
|
if len(closes) >= 4:
|
||||||
@@ -428,6 +482,11 @@ def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict:
|
|||||||
return {"signal": None, "description": "변동성 돌파 미달"}
|
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:
|
async def check_market_regime() -> dict:
|
||||||
"""시장 레짐 판별 (CLAUDE.md 섹션 7.1: 200일 MA + 기울기)
|
"""시장 레짐 판별 (CLAUDE.md 섹션 7.1: 200일 MA + 기울기)
|
||||||
|
|
||||||
@@ -444,7 +503,14 @@ async def check_market_regime() -> dict:
|
|||||||
if not is_market_open():
|
if not is_market_open():
|
||||||
return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
|
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 미지원)
|
# 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)
|
kospi_chart = await korea_investment_api.get_daily_chart("069500", "D", limit=240)
|
||||||
|
|
||||||
if "output2" not in kospi_chart or not kospi_chart["output2"]:
|
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
|
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일 연속 하락
|
# 강한 하락장: 코스피 < MA60 또는 5일 연속 하락
|
||||||
if current_price < ma60 or consecutive_down >= 5:
|
if current_price < ma60 or consecutive_down >= 5:
|
||||||
return {
|
return _cache_and_return({
|
||||||
"regime": "strong_bearish",
|
"regime": "strong_bearish",
|
||||||
"regime_200d": "bear",
|
"regime_200d": "bear",
|
||||||
"message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)",
|
"message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)",
|
||||||
@@ -491,11 +562,11 @@ async def check_market_regime() -> dict:
|
|||||||
"max_trades": 1,
|
"max_trades": 1,
|
||||||
"consecutive_down": consecutive_down,
|
"consecutive_down": consecutive_down,
|
||||||
"drop_from_ma20": round(drop_from_ma20, 1),
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
||||||
}
|
})
|
||||||
|
|
||||||
# 약세장: 코스피 < MA20
|
# 약세장: 코스피 < MA20
|
||||||
if current_price < ma20:
|
if current_price < ma20:
|
||||||
return {
|
return _cache_and_return({
|
||||||
"regime": "bearish",
|
"regime": "bearish",
|
||||||
"regime_200d": "bear",
|
"regime_200d": "bear",
|
||||||
"message": f"하락장 (코스피: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})",
|
"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,
|
"max_trades": settings.BEAR_MARKET_MAX_TRADES,
|
||||||
"consecutive_down": consecutive_down,
|
"consecutive_down": consecutive_down,
|
||||||
"drop_from_ma20": round(drop_from_ma20, 1),
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
||||||
}
|
})
|
||||||
|
|
||||||
# 상승장: 200일 MA 기반
|
# 상승장: 200일 MA 기반
|
||||||
return {
|
return _cache_and_return({
|
||||||
"regime": "bullish",
|
"regime": "bullish",
|
||||||
"regime_200d": "bull" if is_bull_200 else "bear",
|
"regime_200d": "bull" if is_bull_200 else "bear",
|
||||||
"message": f"상승장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})",
|
"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,
|
"max_trades": settings.MAX_DAILY_TRADES,
|
||||||
"consecutive_down": consecutive_down,
|
"consecutive_down": consecutive_down,
|
||||||
"drop_from_ma20": round(drop_from_ma20, 1),
|
"drop_from_ma20": round(drop_from_ma20, 1),
|
||||||
}
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"시장 지수 확인 실패: {e}")
|
logger.error(f"시장 지수 확인 실패: {e}")
|
||||||
return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5}
|
return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5}
|
||||||
@@ -1136,6 +1207,65 @@ async def get_rule_based_recommendations(
|
|||||||
return result
|
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:
|
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
|
||||||
"""RSI+볼린저 평균회귀 매수 조건 확인 (CLAUDE.md 섹션 7.2)
|
"""RSI+볼린저 평균회귀 매수 조건 확인 (CLAUDE.md 섹션 7.2)
|
||||||
|
|
||||||
@@ -1181,65 +1311,17 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
|||||||
"""DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)"""
|
"""DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)"""
|
||||||
|
|
||||||
logger.info("Rule-based recommendations from DB")
|
logger.info("Rule-based recommendations from DB")
|
||||||
|
|
||||||
# JSON 파일에서 규칙 로드
|
active_rules = await _load_active_rules_from_db(db)
|
||||||
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)")
|
|
||||||
|
|
||||||
logger.info(f"Found {len(active_rules)} active trading rules")
|
logger.info(f"Found {len(active_rules)} active trading rules")
|
||||||
|
|
||||||
# 오늘 날짜의 분석 데이터 조회 (중복 종목은 score 높은 것 우선)
|
# 오늘·어제 분석 데이터 조회 (KST 날짜 기준, 배치 미실행 시 전날 데이터 사용)
|
||||||
today = date.today()
|
today_kst = datetime.now(KST).date()
|
||||||
|
since_date = today_kst - timedelta(days=1) # 어제~오늘 포함
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(StockAnalysis)
|
select(StockAnalysis)
|
||||||
.where(StockAnalysis.analysis_date >= today)
|
.where(StockAnalysis.analysis_date >= since_date)
|
||||||
.order_by(StockAnalysis.score.desc())
|
.order_by(StockAnalysis.analysis_date.desc(), StockAnalysis.score.desc())
|
||||||
)
|
)
|
||||||
raw_stocks = result.scalars().all()
|
raw_stocks = result.scalars().all()
|
||||||
# 종목코드 기준 중복 제거 (score 내림차순 정렬 후 첫 번째만)
|
# 종목코드 기준 중복 제거 (score 내림차순 정렬 후 첫 번째만)
|
||||||
@@ -1264,7 +1346,7 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
|||||||
"message": "⚠️ 배치 분석 데이터 없음. 'make batch-test' 명령어로 분석 실행 필요."
|
"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 = []
|
rule_matches = []
|
||||||
@@ -1313,38 +1395,13 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
|||||||
# 여기까지 왔으면 rule_matched_stocks에 추가
|
# 여기까지 왔으면 rule_matched_stocks에 추가
|
||||||
rule_matched_stocks.append(stock)
|
rule_matched_stocks.append(stock)
|
||||||
|
|
||||||
# 점수순 정렬
|
# 점수순 정렬 (score=None 안전 처리)
|
||||||
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
|
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
|
||||||
|
|
||||||
# 이하 로직은 그대로 (Top N, rule_matches, stock_to_rules, 실시간 가격 업데이트 등)
|
# 이하 로직은 그대로 (Top N, rule_matches, stock_to_rules, 실시간 가격 업데이트 등)
|
||||||
rule_stocks = []
|
rule_stocks = []
|
||||||
for stock in rule_matched_stocks[:10]:
|
for stock in rule_matched_stocks[:10]:
|
||||||
stock_dict = {
|
stock_dict = _stock_analysis_to_dict(stock, rule=rule, actions=actions)
|
||||||
"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),
|
|
||||||
}
|
|
||||||
rule_stocks.append(stock_dict)
|
rule_stocks.append(stock_dict)
|
||||||
rule_matches.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:
|
if stock.stock_code in already_added:
|
||||||
continue
|
continue
|
||||||
already_added.add(stock.stock_code)
|
already_added.add(stock.stock_code)
|
||||||
stock_dict = {
|
stock_dict = _stock_analysis_to_dict(stock)
|
||||||
"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,
|
|
||||||
}
|
|
||||||
fallback_stocks.append(stock_dict)
|
fallback_stocks.append(stock_dict)
|
||||||
rule_matches.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": rule_matches,
|
||||||
"recommendations_by_rule": recommendations_by_rule,
|
"recommendations_by_rule": recommendations_by_rule,
|
||||||
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
|
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
|
||||||
# DB에 저장된 실제 배치 분석 시각 (API 요청 시각이 아님)
|
# DB에 저장된 실제 배치 분석 날짜 (TIMESTAMP/DATE 모두 YYYY-MM-DD 반환)
|
||||||
"batch_time": max(
|
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
|
||||||
(s.analysis_date for s in all_analyzed_stocks),
|
max((s.analysis_date for s in all_analyzed_stocks), default=date.today())
|
||||||
default=datetime.now()
|
|
||||||
).isoformat(),
|
).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def get_rule_based_recommendations_realtime():
|
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)")
|
logger.info("Rule-based recommendations requested (realtime mode)")
|
||||||
|
|
||||||
# 샘플 규칙 생성 (실제로는 DB에서 조회)
|
async with AsyncSessionLocal() as _db:
|
||||||
from collections import namedtuple
|
active_rules = await _load_active_rules_from_db(_db)
|
||||||
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
|
logger.info(f"Found {len(active_rules)} active trading rules (realtime)")
|
||||||
|
|
||||||
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
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# active_rules가 비어있으면 기본 지표로 분석
|
# active_rules가 비어있으면 기본 지표로 분석
|
||||||
if not 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")
|
logger.info(f"Found {len(active_rules)} active trading rules")
|
||||||
|
|
||||||
# 분석할 종목 리스트 (타임아웃 방지를 위해 10개로 제한)
|
# 분석할 종목 리스트
|
||||||
all_stocks = await get_all_tradable_stocks()
|
all_stocks = await get_all_tradable_stocks()
|
||||||
stocks_to_analyze = all_stocks[:10] # 상위 10개 종목으로 제한
|
stocks_to_analyze = all_stocks[:settings.ANALYSIS_STOCK_COUNT]
|
||||||
|
|
||||||
# 보유 포트폴리오 종목 추가 (보유 종목은 거래대금 순위 무관하게 항상 분석)
|
# 보유 포트폴리오 종목 추가 (보유 종목은 거래대금 순위 무관하게 항상 분석)
|
||||||
try:
|
try:
|
||||||
@@ -1653,54 +1626,44 @@ async def get_rule_based_recommendations_realtime():
|
|||||||
|
|
||||||
logger.info(f"Successfully analyzed {len(valid_analyzed_stocks)} stocks")
|
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 = []
|
rule_matches = []
|
||||||
recommendations_by_rule = []
|
recommendations_by_rule = []
|
||||||
|
|
||||||
for rule in active_rules:
|
for rule in active_rules:
|
||||||
logger.info(f"Applying rule: {rule.name}")
|
logger.info(f"Applying rule: {rule.name}")
|
||||||
|
|
||||||
# 규칙 조건 파싱
|
|
||||||
conditions = rule.conditions
|
conditions = rule.conditions
|
||||||
actions = rule.actions
|
actions = rule.actions
|
||||||
|
|
||||||
# 이미 분석된 종목들 중에서 규칙에 맞는 종목 필터링
|
|
||||||
rule_matched_stocks = []
|
rule_matched_stocks = []
|
||||||
|
|
||||||
for stock in valid_analyzed_stocks:
|
for stock in valid_analyzed_stocks:
|
||||||
# ETF/레버리지/인버스/KODEX/선물 종목 제외
|
# ETF/레버리지/인버스/KODEX/선물 종목 제외
|
||||||
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
|
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
|
||||||
if any(k in stock.stock_name for k in etf_keywords):
|
if any(k in stock.stock_name for k in etf_keywords):
|
||||||
continue
|
continue
|
||||||
# 규칙 조건 체크
|
|
||||||
matches = True
|
# === 통합 규칙 조건 매칭 (DB 모드와 동일한 로직) ===
|
||||||
|
if rule.rule_type == "MEAN_REVERSION":
|
||||||
# RSI 조건 체크
|
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime_str)
|
||||||
if "rsi_threshold" in conditions:
|
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
|
||||||
rsi_threshold = conditions["rsi_threshold"]
|
matches = False # 미구현 (DB 모드와 동일)
|
||||||
if not any(f"RSI" in signal for signal in stock.signals):
|
else:
|
||||||
matches = False
|
# BUY 타입: RSI 임계값 체크
|
||||||
|
rsi_threshold = conditions.get("rsi_threshold", 40)
|
||||||
# Trend filter: only allow long entries in uptrend
|
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
|
||||||
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
|
|
||||||
|
|
||||||
if matches:
|
if matches:
|
||||||
rule_matched_stocks.append(stock)
|
rule_matched_stocks.append(stock)
|
||||||
|
|
||||||
# 점수순 정렬
|
# 점수순 정렬 (score=None 안전 처리)
|
||||||
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
|
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
|
||||||
|
|
||||||
# 규칙별로 종목 저장
|
# 규칙별로 종목 저장
|
||||||
rule_stocks = []
|
rule_stocks = []
|
||||||
for rec in rule_matched_stocks[:10]: # 규칙당 상위 10개
|
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"]
|
stock["change_rate"] = updated["change_rate"]
|
||||||
|
|
||||||
# 실시간 분석 결과를 DB에 저장 (규칙 매칭 여부와 무관하게 분석된 전 종목 저장)
|
# 실시간 분석 결과를 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
|
from datetime import date as _date
|
||||||
_today = _date.today()
|
_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:
|
async with AsyncSessionLocal() as _db:
|
||||||
# valid_analyzed_stocks = StockRecommendation Pydantic 모델 리스트
|
saved = await _bulk_upsert(_db, _items, _today)
|
||||||
# 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,
|
|
||||||
))
|
|
||||||
await _db.commit()
|
await _db.commit()
|
||||||
logger.info(f"✅ [Batch] DB upsert 완료: {len(valid_analyzed_stocks)}종목 저장")
|
logger.info(f"✅ [Batch] DB upsert 완료: {saved}종목 저장")
|
||||||
return {
|
return {
|
||||||
"total": len(rule_matches),
|
"total": len(rule_matches),
|
||||||
"active_rules": len(active_rules),
|
"active_rules": len(active_rules),
|
||||||
@@ -2614,7 +2527,7 @@ async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
|
|||||||
if total_processed >= max_trades:
|
if total_processed >= max_trades:
|
||||||
break
|
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:
|
if total_processed >= max_trades:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ class Settings(BaseSettings):
|
|||||||
STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2)
|
STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2)
|
||||||
TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값
|
TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값
|
||||||
AUTO_TRADE_ENABLED: bool = True
|
AUTO_TRADE_ENABLED: bool = True
|
||||||
|
ANALYSIS_STOCK_COUNT: int = 150 # 배치 분석 종목 수 (.env로 조정 가능)
|
||||||
|
AUTO_TRADE_TOP_N_PER_RULE: int = 2 # 규칙당 자동매매 실행 최대 종목 수
|
||||||
|
|
||||||
# 포지션 규칙 (섹션 6.1)
|
# 포지션 규칙 (섹션 6.1)
|
||||||
MAX_HOLDINGS_COUNT: int = 20 # 동시 보유 최대 20종목
|
MAX_HOLDINGS_COUNT: int = 20 # 동시 보유 최대 20종목
|
||||||
|
|||||||
@@ -24,9 +24,23 @@ from app.core.market_schedule import is_market_open
|
|||||||
from sqlalchemy import text
|
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(
|
logging.basicConfig(
|
||||||
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
level=_log_level,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format=_log_format,
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.handlers.RotatingFileHandler(
|
||||||
|
_log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8"
|
||||||
|
),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_confidence NUMERIC(5, 4),
|
||||||
ADD COLUMN IF NOT EXISTS ml_signal VARCHAR(10);
|
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)
|
# active_trades에 분할 청산 컬럼 추가 (CLAUDE.md 섹션 7.3)
|
||||||
await conn.execute(text("""
|
await conn.execute(text("""
|
||||||
ALTER TABLE active_trades
|
ALTER TABLE active_trades
|
||||||
|
|||||||
@@ -178,11 +178,14 @@ class MCPToolCall(Base):
|
|||||||
class StockAnalysis(Base):
|
class StockAnalysis(Base):
|
||||||
"""매일 배치로 저장되는 종목 분석 데이터"""
|
"""매일 배치로 저장되는 종목 분석 데이터"""
|
||||||
__tablename__ = "stock_analysis"
|
__tablename__ = "stock_analysis"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("stock_code", "analysis_date", name="uq_stock_analysis_code_date"),
|
||||||
|
)
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
stock_code = Column(String(20), nullable=False, index=True)
|
stock_code = Column(String(20), nullable=False, index=True)
|
||||||
stock_name = Column(String(100), nullable=False)
|
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)
|
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에 저장
|
전체 거래 종목을 분석하여 DB에 저장
|
||||||
1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS)
|
1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS)
|
||||||
2. 차트 데이터 조회 + 기술적 지표 계산
|
2. 차트 데이터 조회 + 기술적 지표 계산
|
||||||
3. 매매 신호 생성 + DB 일괄 저장 (BULK INSERT)
|
3. 매매 신호 생성 + DB Streaming 저장 (분석 즉시 UPSERT)
|
||||||
4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이)
|
4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이)
|
||||||
5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치
|
5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치
|
||||||
|
|
||||||
성능 개선 (이전 대비):
|
성능 개선 (이전 대비):
|
||||||
- BATCH_CONCURRENCY: 5 → 10 (API 세마포어 허용 범위 내)
|
- BATCH_CONCURRENCY: 5 → 10 (API 세마포어 허용 범위 내)
|
||||||
- DB 트랜잭션: 종목당 2개(DELETE+INSERT) → BULK_SAVE_CHUNK(20개)당 1개
|
- DB 트랜잭션: 종목당 2개(DELETE+INSERT) → UPSERT (원자적, race-condition 없음)
|
||||||
- 500종목 기준: 1,000 트랜잭션 → 25 트랜잭션 (40× 감소)
|
- Streaming: 전체 메모리 적재 → 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 저장
|
||||||
|
- DB 엔진: 스크립트 자체 생성 → app.core.database 공통 모듈 사용
|
||||||
|
|
||||||
실행: python batch_daily_analysis.py
|
실행: python batch_daily_analysis.py
|
||||||
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && 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))
|
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.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.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
|
from app.api.endpoints.recommendations import analyze_stock_for_batch, StockRecommendation
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -44,32 +42,11 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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 세마포어 허용 범위 내 동시 처리 수
|
BATCH_CONCURRENCY = 10 # API 세마포어 허용 범위 내 동시 처리 수
|
||||||
BULK_SAVE_CHUNK = 20 # DB 일괄 저장 단위 (트랜잭션 수 = 총종목 / BULK_SAVE_CHUNK)
|
BULK_SAVE_CHUNK = 20 # Streaming 저장 단위 (큐에 20개 쌓이면 즉시 UPSERT)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
@@ -92,13 +69,13 @@ async def detect_and_log_market_regime() -> str:
|
|||||||
# KOSPI 지수 코드 1001
|
# KOSPI 지수 코드 1001
|
||||||
df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001")
|
df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001")
|
||||||
if df is None or len(df) < 205:
|
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"
|
return "UNKNOWN"
|
||||||
|
|
||||||
kospi_closes = df["종가"].tolist()
|
kospi_closes = df["종가"].tolist()
|
||||||
regime = detect_market_regime(kospi_closes)
|
regime = detect_market_regime(kospi_closes)
|
||||||
|
|
||||||
regime_emoji = {"승장": "📈 BULL", "하락장": "📉 BEAR",
|
regime_emoji = {"상승장": "📈 BULL", "하락장": "📉 BEAR",
|
||||||
"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper())
|
"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper())
|
||||||
logger.info("🌏 시장 레짐: %s (KOSPI %s일 기준)", regime_emoji, today)
|
logger.info("🌏 시장 레짐: %s (KOSPI %s일 기준)", regime_emoji, today)
|
||||||
return regime.value.upper()
|
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)
|
name = pykrx_stock.get_market_ticker_name(ticker)
|
||||||
if any(k in name for k in SKIP_KEYWORDS):
|
if any(k in name for k in SKIP_KEYWORDS):
|
||||||
continue
|
continue
|
||||||
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"]))
|
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"], market))
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("❌ %s 종목 조회 실패: %s", market, exc)
|
logger.error("❌ %s 종목 조회 실패: %s", market, exc)
|
||||||
|
|
||||||
stocks_with_cap.sort(key=lambda x: x[2], reverse=True)
|
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')))
|
kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI")
|
||||||
kosdaq_n = len(top_stocks) - kospi_n
|
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)
|
logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
|
||||||
return top_stocks
|
return top_stocks
|
||||||
|
|
||||||
@@ -169,7 +147,7 @@ async def analyze_one_stock(
|
|||||||
stock_code: str,
|
stock_code: str,
|
||||||
stock_name: str,
|
stock_name: str,
|
||||||
) -> tuple[str, Optional[StockRecommendation]]:
|
) -> tuple[str, Optional[StockRecommendation]]:
|
||||||
"""종목 분석만 수행하고 결과 반환. DB 저장은 bulk_save_results에서 일괄 처리.
|
"""종목 분석만 수행하고 결과 반환. DB 저장은 Streaming 저장기에서 처리.
|
||||||
|
|
||||||
Returns: (status, result)
|
Returns: (status, result)
|
||||||
status: "ok" | "no_chart" | "price_filter" | "error"
|
status: "ok" | "no_chart" | "price_filter" | "error"
|
||||||
@@ -182,8 +160,8 @@ async def analyze_one_stock(
|
|||||||
logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
|
logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
|
||||||
return "no_chart", None
|
return "no_chart", None
|
||||||
|
|
||||||
# 5,000원 이하 저가주 제외 (CLAUDE.md 6.4)
|
# 5,000원 이하 저가주 제외 (CLAUDE.md 5절 가격 필터)
|
||||||
if result.current_price and result.current_price < 5000:
|
if not passes_price_filter(result):
|
||||||
logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price)
|
logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price)
|
||||||
return "price_filter", None
|
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(
|
async def _streaming_saver(
|
||||||
stock_code: str,
|
queue: asyncio.Queue,
|
||||||
stock_name: str,
|
|
||||||
result: StockRecommendation,
|
|
||||||
analysis_date: date,
|
analysis_date: date,
|
||||||
) -> StockAnalysis:
|
) -> dict:
|
||||||
"""StockRecommendation Pydantic 모델 → StockAnalysis ORM 객체 변환"""
|
"""큐에서 분석 결과를 꺼내 BULK_SAVE_CHUNK(20)개마다 즉시 DB UPSERT.
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
전체 메모리 적재 없이 분석 완료 즉시 저장하여 메모리 사용 최소화.
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
Returns: stats dict {"ok", "no_chart", "price_filter", "error", "saved"}
|
||||||
# 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: 저장된 건수
|
|
||||||
"""
|
"""
|
||||||
if not items:
|
buffer: list[tuple[str, str, StockRecommendation]] = []
|
||||||
return 0
|
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:
|
status, code, name, result = item
|
||||||
# 기존 당일 데이터 일괄 삭제 (IN 쿼리 1회)
|
stats[status] = stats.get(status, 0) + 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()
|
|
||||||
|
|
||||||
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 호출 없이 신호 종목 추출
|
if buffer:
|
||||||
# -------------------------------------------------------------------
|
|
||||||
|
|
||||||
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:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(
|
saved = await bulk_upsert_analysis(session, buffer, analysis_date)
|
||||||
select(
|
await session.commit()
|
||||||
StockAnalysis.stock_code,
|
stats["saved"] += saved
|
||||||
StockAnalysis.current_price,
|
logger.info("💾 DB 저장(잔여): %d건 누적", stats["saved"])
|
||||||
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()
|
return stats
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# 7. 메인 배치
|
# 6. 메인 배치
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|
||||||
async def batch_analyze_all_stocks(limit: int = None):
|
async def batch_analyze_all_stocks(limit: int = None):
|
||||||
@@ -353,13 +233,14 @@ async def batch_analyze_all_stocks(limit: int = None):
|
|||||||
1. 시장 레짐 판별 (KOSPI 지수 기준)
|
1. 시장 레짐 판별 (KOSPI 지수 기준)
|
||||||
2. 시가총액 상위 500개 종목 조회
|
2. 시가총액 상위 500개 종목 조회
|
||||||
3. 세마포어로 BATCH_CONCURRENCY(10)개씩 병렬 분석 (API 호출만)
|
3. 세마포어로 BATCH_CONCURRENCY(10)개씩 병렬 분석 (API 호출만)
|
||||||
4. 분석 완료 후 BULK_SAVE_CHUNK(20)개씩 일괄 DB 저장
|
4. Streaming: 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 DB UPSERT
|
||||||
5. DB 스캐너 실행 (추가 API 호출 없음)
|
5. DB 스캐너 실행 (추가 API 호출 없음)
|
||||||
6. 워치리스트 초기화 — 스캐너 신호 종목 우선
|
6. 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||||
|
|
||||||
성능 개선 (기존 대비):
|
성능 개선 (기존 대비):
|
||||||
- DB 트랜잭션: 1,000회 → 50회 (20× 감소)
|
- DB 트랜잭션: DELETE+INSERT race-condition → 원자적 UPSERT
|
||||||
- 동시 처리: 5 → 10 종목
|
- 메모리: 전체 결과 적재 → Streaming(분석 즉시 저장)
|
||||||
|
- DB 엔진: 스크립트 자체 생성 → 공통 모듈 공유
|
||||||
"""
|
"""
|
||||||
start_time = datetime.now(KST)
|
start_time = datetime.now(KST)
|
||||||
analysis_date = datetime.now(KST).date()
|
analysis_date = datetime.now(KST).date()
|
||||||
@@ -371,45 +252,47 @@ async def batch_analyze_all_stocks(limit: int = None):
|
|||||||
# Step 1: 시장 레짐 판별
|
# Step 1: 시장 레짐 판별
|
||||||
regime = await detect_and_log_market_regime()
|
regime = await detect_and_log_market_regime()
|
||||||
|
|
||||||
# Step 2: 종목 목록 조회
|
# Step 2: 종목 목록 조회 (모듈 레벨 싱글톤 재사용, 불필요한 새 인스턴스 생성 금지)
|
||||||
kis_api = KoreaInvestmentAPI()
|
all_stocks = await get_all_tradable_stocks(korea_investment_api, limit=500)
|
||||||
all_stocks = await get_all_tradable_stocks(kis_api, limit=500)
|
|
||||||
filtered_stocks = all_stocks[:limit] if limit else all_stocks
|
filtered_stocks = all_stocks[:limit] if limit else all_stocks
|
||||||
total = len(filtered_stocks)
|
total = len(filtered_stocks)
|
||||||
logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime)
|
logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime)
|
||||||
|
|
||||||
# Step 3: 병렬 분석 — 세마포어로 BATCH_CONCURRENCY 제한
|
# Step 3 + 4: 병렬 분석 & Streaming 저장
|
||||||
# asyncio 단일 스레드이므로 results 리스트 append는 thread-safe
|
# 분석 태스크 → queue → _streaming_saver (동시 실행)
|
||||||
|
queue: asyncio.Queue = asyncio.Queue(maxsize=BULK_SAVE_CHUNK * 3)
|
||||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
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 def analyze_one(i: int, code: str, name: str):
|
||||||
async with semaphore:
|
try:
|
||||||
status, result = await analyze_one_stock(i, total, code, name)
|
async with semaphore:
|
||||||
all_results.append((status, code, name, result))
|
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)]
|
tasks = [analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)]
|
||||||
await asyncio.gather(*tasks, return_exceptions=True)
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Step 4: 통계 집계
|
# sentinel 전송 → 저장기 종료
|
||||||
ok_items = [(code, name, r) for status, code, name, r in all_results if status == "ok" and r]
|
await queue.put(None)
|
||||||
ok_count = len(ok_items)
|
stats = await saver_task
|
||||||
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))
|
|
||||||
|
|
||||||
# Step 5: BULK_SAVE_CHUNK 단위 일괄 DB 저장
|
ok_count = stats.get("ok", 0)
|
||||||
save_count = 0
|
no_chart = stats.get("no_chart", 0)
|
||||||
for chunk_start in range(0, len(ok_items), BULK_SAVE_CHUNK):
|
price_flt = stats.get("price_filter", 0)
|
||||||
chunk = ok_items[chunk_start: chunk_start + BULK_SAVE_CHUNK]
|
err_count = stats.get("error", 0)
|
||||||
saved = await bulk_save_results(chunk, analysis_date)
|
save_count = stats.get("saved", 0)
|
||||||
save_count += saved
|
|
||||||
logger.info("💾 DB 저장: %d/%d건 완료", save_count, ok_count)
|
|
||||||
|
|
||||||
# Step 6: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
|
# Step 5: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
|
||||||
scanner_tickers = await run_db_scanner(analysis_date)
|
scanner_tickers = await run_db_scanner(analysis_date)
|
||||||
|
|
||||||
# Step 7: 워치리스트 초기화 — 스캐너 신호 종목 우선
|
# Step 6: 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||||
try:
|
try:
|
||||||
from app.services.watchlist import initialize_watchlist
|
from app.services.watchlist import initialize_watchlist
|
||||||
async with AsyncSessionLocal() as session:
|
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()
|
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("=" * 60)
|
||||||
logger.info("✅ Batch analysis completed!")
|
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("🔍 DB 스캐너 신호: %d개", len(scanner_tickers))
|
||||||
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
|
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
|
||||||
logger.info("📡 API 통계 — 호출:%d 오류:%d(%.1f%%) 평균:%.2f초",
|
logger.info("📡 API 통계 — 호출:%d 오류:%d(%.1f%%) 평균:%.2f초",
|
||||||
stats['total_calls'], stats['total_errors'],
|
api_stats['total_calls'], api_stats['total_errors'],
|
||||||
stats['error_rate'] * 100, stats['avg_duration'])
|
api_stats['error_rate'] * 100, api_stats['avg_duration'])
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,21 +21,20 @@ KST = pytz.timezone('Asia/Seoul')
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
from sqlalchemy import select, delete, update, func
|
from sqlalchemy import select, update, func
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||||
from app.core.market_schedule import is_market_open
|
from app.core.market_schedule import is_market_open
|
||||||
from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory
|
from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory
|
||||||
from app.api.endpoints.recommendations import analyze_stock_for_batch
|
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 (
|
from app.services.watchlist import (
|
||||||
WATCHLIST_MAX_SIZE,
|
WATCHLIST_MAX_SIZE,
|
||||||
WATCHLIST_ENTRY_SCORE,
|
WATCHLIST_ENTRY_SCORE,
|
||||||
WATCHLIST_EXIT_SCORE,
|
WATCHLIST_EXIT_SCORE,
|
||||||
)
|
)
|
||||||
from batch_daily_analysis import run_db_scanner
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -49,25 +48,6 @@ BATCH_CONCURRENCY = 5
|
|||||||
# 워치리스트 외부 후보 재분석 상한 (10분 배치 내에 처리 가능한 수준)
|
# 워치리스트 외부 후보 재분석 상한 (10분 배치 내에 처리 가능한 수준)
|
||||||
OUTSIDE_RESCORE_LIMIT = 30
|
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]]:
|
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)가 먼저 실행되었는지 확인하세요."
|
"아침 배치(batch_daily_analysis.py)가 먼저 실행되었는지 확인하세요."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"📋 분석종목 {len(rows)}개 대상")
|
logger.info("📋 분석종목 %d개 대상", len(rows))
|
||||||
return [(row.stock_code, row.stock_name) for row in 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(
|
async def analyze_update_and_manage(
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
stock_name: str,
|
stock_name: str,
|
||||||
analysis_date: date,
|
analysis_date: date,
|
||||||
) -> tuple[bool, int | None]:
|
) -> tuple[bool, int | None, bool]:
|
||||||
"""종목 재분석 → stock_analysis 갱신 → watchlist_history 저장 → watchlist 관리.
|
"""종목 재분석 → stock_analysis 갱신 → watchlist_history 저장 → watchlist 관리.
|
||||||
|
|
||||||
Returns: (success, score)
|
Returns: (success, score, was_removed)
|
||||||
|
was_removed: _manage_watchlist_membership이 실제로 비활성화 처리했으면 True
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||||||
if not result:
|
if not result:
|
||||||
logger.info(f"No result for {stock_code} (chart/price issue)")
|
logger.info("No result for %s (chart/price issue)", stock_code)
|
||||||
return False, None
|
return False, None, False
|
||||||
|
|
||||||
# 5,000원 이하 저가주 → watchlist에서도 제거
|
# 5,000원 이하 저가주 → watchlist에서도 제거
|
||||||
if result.current_price and result.current_price < 5000:
|
if not passes_price_filter(result):
|
||||||
logger.debug(f"Skip {stock_code}: price={result.current_price:.0f}원 < 5,000원")
|
logger.debug("Skip %s: price=%.0f원 < 5,000원", stock_code, result.current_price)
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
await session.execute(
|
await session.execute(
|
||||||
update(WatchlistStock)
|
update(WatchlistStock)
|
||||||
@@ -120,83 +165,26 @@ async def analyze_update_and_manage(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return False, None
|
return False, None, True # 가격필터 제거도 was_removed=True
|
||||||
|
|
||||||
now = datetime.now(KST)
|
now = datetime.now(KST)
|
||||||
score = result.score
|
score = result.score
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 1. stock_analysis 갱신 (DELETE + INSERT)
|
await _save_analysis_and_history(
|
||||||
await session.execute(
|
session, stock_code, stock_name, result, analysis_date, now
|
||||||
delete(StockAnalysis).where(
|
)
|
||||||
StockAnalysis.stock_code == stock_code,
|
was_removed = await _manage_watchlist_membership(
|
||||||
StockAnalysis.analysis_date == analysis_date,
|
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()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(f"✅ {stock_code} ({stock_name}): score={score}")
|
logger.info("✅ %s (%s): score=%s", stock_code, stock_name, score)
|
||||||
return True, score
|
return True, score, was_removed
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Failed {stock_code}: {e}")
|
logger.error("❌ Failed %s: %s", stock_code, e)
|
||||||
return False, None
|
return False, None, False
|
||||||
|
|
||||||
|
|
||||||
async def rescore_outside_candidates(
|
async def rescore_outside_candidates(
|
||||||
@@ -232,7 +220,7 @@ async def rescore_outside_candidates(
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
total = len(candidates)
|
total = len(candidates)
|
||||||
logger.info(f"🔍 워치리스트 외부 후보 {total}개 재분석 시작")
|
logger.info("🔍 워치리스트 외부 후보 %d개 재분석 시작", total)
|
||||||
|
|
||||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||||
|
|
||||||
@@ -242,48 +230,19 @@ async def rescore_outside_candidates(
|
|||||||
res = await analyze_stock_for_batch(code, name)
|
res = await analyze_stock_for_batch(code, name)
|
||||||
if not res:
|
if not res:
|
||||||
return False
|
return False
|
||||||
# 저가주 필터 (CLAUDE.md 섹션 6.4)
|
# 저가주 필터 (CLAUDE.md 5절 가격 필터)
|
||||||
if res.current_price and res.current_price < 5000:
|
if not passes_price_filter(res):
|
||||||
logger.debug(f"Skip {code}: price={res.current_price:.0f}원 < 5,000원")
|
logger.debug("Skip %s: price=%.0f원 < 5,000원", code, res.current_price)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
await session.execute(
|
await upsert_analysis(session, code, name, res, analysis_date)
|
||||||
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 session.commit()
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 외부 후보 재분석 실패 {code}: {e}")
|
logger.error("❌ 외부 후보 재분석 실패 %s: %s", code, e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
@@ -292,7 +251,7 @@ async def rescore_outside_candidates(
|
|||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
success = sum(1 for r in results if r is 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
|
return success
|
||||||
|
|
||||||
|
|
||||||
@@ -316,7 +275,7 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
|||||||
|
|
||||||
slots = WATCHLIST_MAX_SIZE - active_count
|
slots = WATCHLIST_MAX_SIZE - active_count
|
||||||
if slots <= 0:
|
if slots <= 0:
|
||||||
logger.debug(f"Watchlist 최대치 도달 ({active_count}/{WATCHLIST_MAX_SIZE})")
|
logger.debug("Watchlist 최대치 도달 (%d/%d)", active_count, WATCHLIST_MAX_SIZE)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# stock_analysis에서 고점수 종목 중 현재 watchlist에 없는 것 조회
|
# stock_analysis에서 고점수 종목 중 현재 watchlist에 없는 것 조회
|
||||||
@@ -353,8 +312,8 @@ async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"➕ 분석종목 {len(candidates)}개 신규 추가 "
|
"➕ 분석종목 %d개 신규 추가 (score ≥ %d, 현재 %d개)",
|
||||||
f"(score ≥ {WATCHLIST_ENTRY_SCORE}, 현재 {active_count + len(candidates)}개)"
|
len(candidates), WATCHLIST_ENTRY_SCORE, active_count + len(candidates),
|
||||||
)
|
)
|
||||||
return len(candidates)
|
return len(candidates)
|
||||||
|
|
||||||
@@ -369,8 +328,8 @@ async def main():
|
|||||||
start_time = datetime.now(KST)
|
start_time = datetime.now(KST)
|
||||||
analysis_date = datetime.now(KST).date()
|
analysis_date = datetime.now(KST).date()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"⚡ [Intraday] {start_time.strftime('%H:%M:%S')} 시작 — "
|
"⚡ [Intraday] %s 시작 — 분석종목 재분석 (진입≥%d, 제거<%d)",
|
||||||
f"분석종목 재분석 (진입≥{WATCHLIST_ENTRY_SCORE}, 제거<{WATCHLIST_EXIT_SCORE})"
|
start_time.strftime("%H:%M:%S"), WATCHLIST_ENTRY_SCORE, WATCHLIST_EXIT_SCORE,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 활성 분석종목 조회
|
# 활성 분석종목 조회
|
||||||
@@ -385,9 +344,9 @@ async def main():
|
|||||||
# 병렬 재분석
|
# 병렬 재분석
|
||||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
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:
|
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)
|
return await analyze_update_and_manage(code, name, analysis_date)
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
@@ -396,15 +355,11 @@ async def main():
|
|||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
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)
|
analyzed_ok = sum(1 for r in results if isinstance(r, tuple) and r[0] is True)
|
||||||
removed = sum(
|
removed = sum(1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[2] is True)
|
||||||
1 for r in results
|
retained = analyzed_ok - sum(
|
||||||
if isinstance(r, tuple) and r[0] is True
|
1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[0] is True and r[2] is True
|
||||||
and r[1] is not None and r[1] < WATCHLIST_EXIT_SCORE
|
|
||||||
)
|
)
|
||||||
retained = analyzed_ok - removed # 워치리스트에 실제 유지된 종목 수
|
|
||||||
fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False)
|
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))
|
error = sum(1 for r in results if isinstance(r, Exception))
|
||||||
|
|
||||||
@@ -419,9 +374,8 @@ async def main():
|
|||||||
now_kst = datetime.now(KST)
|
now_kst = datetime.now(KST)
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# 현재 활성 종목 수 확인
|
# 현재 활성 종목 수 확인
|
||||||
from sqlalchemy import func as sa_func
|
|
||||||
count_result = await session.execute(
|
count_result = await session.execute(
|
||||||
select(sa_func.count())
|
select(func.count())
|
||||||
.select_from(WatchlistStock)
|
.select_from(WatchlistStock)
|
||||||
.where(WatchlistStock.is_active == True)
|
.where(WatchlistStock.is_active == True)
|
||||||
)
|
)
|
||||||
@@ -453,7 +407,7 @@ async def main():
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if added_scanner:
|
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 중복 방지용 갱신
|
current_codes |= scanner_new # refresh_watchlist 중복 방지용 갱신
|
||||||
|
|
||||||
# 고점수 신규 종목 추가 (스캐너 추가분 제외)
|
# 고점수 신규 종목 추가 (스캐너 추가분 제외)
|
||||||
@@ -461,12 +415,11 @@ async def main():
|
|||||||
|
|
||||||
duration = (datetime.now(KST) - start_time).total_seconds()
|
duration = (datetime.now(KST) - start_time).total_seconds()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ [Intraday] 완료: {retained}/{total} 유지, "
|
"✅ [Intraday] 완료: %d/%d 유지, %d 제거, +%d 신규(score), +%d 신규(스캐너), "
|
||||||
f"{removed} 제거, +{added} 신규(score), +{added_scanner} 신규(스캐너), "
|
"%d 실패, %d 오류 — %.1f초",
|
||||||
f"{fail} 실패, {error} 오류 — {duration:.1f}초"
|
retained, total, removed, added, added_scanner, fail, error, duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(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_DB=${POSTGRES_DB}
|
||||||
- POSTGRES_USER=${POSTGRES_USER}
|
- POSTGRES_USER=${POSTGRES_USER}
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
- SCHEDULE=0 17 * * * # UTC 17:00 = KST 02:00
|
- SCHEDULE=0 8,17 * * *
|
||||||
- BACKUP_KEEP_DAYS=7
|
- BACKUP_KEEP_DAYS=7
|
||||||
- BACKUP_KEEP_WEEKS=4
|
- BACKUP_KEEP_WEEKS=4
|
||||||
- BACKUP_KEEP_MONTHS=3
|
- 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_analysis_date ON stock_analysis(analysis_date);
|
||||||
CREATE INDEX IF NOT EXISTS idx_stock_analysis_score_desc ON stock_analysis(score DESC);
|
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
|
CREATE TRIGGER update_stock_analysis_updated_at BEFORE UPDATE ON stock_analysis
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import './Dashboard.css'
|
|||||||
const formatTime = (isoStr?: string) => {
|
const formatTime = (isoStr?: string) => {
|
||||||
if (!isoStr) return null;
|
if (!isoStr) return null;
|
||||||
const d = new Date(isoStr);
|
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({
|
const { data: mcpStatus } = useQuery({
|
||||||
queryKey: ['mcp-status'],
|
queryKey: ['mcp-status'],
|
||||||
queryFn: async () => (await api.get('/mcp/status')).data,
|
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'],
|
queryKey: ['rule-based-recommendations'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/recommendations/rule-based', {
|
const response = await api.get('/recommendations/rule-based', {
|
||||||
@@ -95,8 +96,9 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
refetchInterval: isMarketOpen ? 600000 : false, // 10분마다 배치 실행
|
refetchInterval: isMarketOpen ? 120000 : 300000, // 장중 2분, 장외 5분
|
||||||
// 장외에도 초기 로드는 실행 (DB 조회는 빠름)
|
staleTime: isMarketOpen ? 110000 : 290000, // refetchInterval보다 약간 짧게 → 재진입 시 즉시 표시
|
||||||
|
refetchOnWindowFocus: false, // 탭 전환 시 불필요한 재요청 방지 (자동매매 중복 트리거 차단)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: chartData, isLoading: chartLoading } = useQuery({
|
const { data: chartData, isLoading: chartLoading } = useQuery({
|
||||||
@@ -201,13 +203,11 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [wsMessages, queryClient])
|
}, [wsMessages, queryClient])
|
||||||
|
|
||||||
// 보유종목 + 분석종목 전체 코드 수집 (현재가 일괄 조회용)
|
// 보유종목 코드만 수집 (현재가 일괄 실시간 조회용)
|
||||||
|
// 분석종목은 stock_analysis.current_price (DB값)를 폴백으로 사용하므로 실시간 조회 불필요
|
||||||
const allStockCodes = useMemo(() => {
|
const allStockCodes = useMemo(() => {
|
||||||
const codes = new Set<string>()
|
return (portfolio || []).map((s: any) => s.stock_code)
|
||||||
;(portfolio || []).forEach((s: any) => codes.add(s.stock_code))
|
}, [portfolio])
|
||||||
;(ruleBasedRecommendations?.analyzed_stock_list || []).forEach((s: any) => codes.add(s.stock_code))
|
|
||||||
return Array.from(codes)
|
|
||||||
}, [portfolio, ruleBasedRecommendations])
|
|
||||||
|
|
||||||
// 현재가 일괄 실시간 조회 (보유종목·분석종목 동기화)
|
// 현재가 일괄 실시간 조회 (보유종목·분석종목 동기화)
|
||||||
const { data: livePrices } = useQuery({
|
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">
|
<span className="text-xs bg-purple-500/20 text-purple-300 px-2 py-0.5 rounded-full">
|
||||||
{ruleBasedRecommendations?.analyzed_stocks || 0}
|
{ruleBasedRecommendations?.analyzed_stocks || 0}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
<div className="divide-y divide-gray-700/50 overflow-y-auto custom-scrollbar flex-1">
|
||||||
{ruleBasedRecommendations === undefined ? (
|
{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">
|
<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" />
|
<RefreshCw className="w-6 h-6 animate-spin mb-2 text-purple-400" />
|
||||||
<p>분석 종목 로딩 중...</p>
|
<p>분석 종목 로딩 중...</p>
|
||||||
@@ -790,8 +803,8 @@ const Dashboard = () => {
|
|||||||
{journals && journals.length > 0 ? (
|
{journals && journals.length > 0 ? (
|
||||||
journals.map((journal: any) => {
|
journals.map((journal: any) => {
|
||||||
const journalDate = new Date(journal.journal_date || journal.created_at);
|
const journalDate = new Date(journal.journal_date || journal.created_at);
|
||||||
const dateStr = journalDate.toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' });
|
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 });
|
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 isGoodMood = journal.mood === '좋음' || journal.mood === 'good' || journal.mood === 'positive';
|
||||||
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative';
|
const isBadMood = journal.mood === '나쁨' || journal.mood === 'bad' || journal.mood === 'negative';
|
||||||
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : [];
|
const tags: string[] = Array.isArray(journal.tags) ? journal.tags : [];
|
||||||
|
|||||||
Reference in New Issue
Block a user