수정본
This commit is contained in:
@@ -11,11 +11,11 @@
|
||||
- 횡보장: 평균회귀 확대, 데이트레이딩 축소
|
||||
- 급락 후 반등: 공격적 매수, 포지션 확대
|
||||
|
||||
[모드 전환 규칙]
|
||||
[모드 전환 규칙] CLAUDE.md 섹션 8.5
|
||||
Aggressive: ATR백분위 < 30 AND ADX > 25 AND 5일수익률 > +2%
|
||||
Normal: 기본
|
||||
Defensive: ATR백분위 > 70 OR 5일수익률 < -3%
|
||||
Crisis: ATR백분위 > 90 OR 5일수익률 < -5% OR MDD > -10%
|
||||
Crisis: ATR백분위 > 90 OR 5일수익률 < -5% OR MDD < -10%
|
||||
|
||||
[통합 방식]
|
||||
기존 config.py 값은 Normal 모드 기본값으로 유지.
|
||||
@@ -298,32 +298,32 @@ def _determine_trading_mode(
|
||||
"""모드 전환 규칙 적용 (히스테리시스 포함).
|
||||
|
||||
[우선순위: Crisis > Defensive > Aggressive > Normal]
|
||||
|
||||
Crisis: ATR백분위 > 95 OR 5일수익률 < -8% OR MDD < -15%
|
||||
Defensive: ATR백분위 > 80 OR 5일수익률 < -5%
|
||||
CLAUDE.md 섹션 8.5:
|
||||
Crisis: ATR백분위 > 90 OR 5일수익률 < -5% OR MDD < -10%
|
||||
Defensive: ATR백분위 > 70 OR 5일수익률 < -3%
|
||||
Aggressive: ATR백분위 < 30 AND ADX > 25 AND 5일수익률 > +2%
|
||||
Normal: 기본
|
||||
|
||||
[히스테리시스] 현재 모드에서 해제되려면 진입 기준보다 완화된 조건 필요.
|
||||
예: Defensive 진입 ATR>80, 해제 ATR<70 (10%p 밴드)
|
||||
예: Defensive 진입 ATR>70, 해제 ATR<60 (10%p 밴드)
|
||||
→ 경계선에서 빈번한 전환(whipsaw) 방지.
|
||||
"""
|
||||
prev_mode = _current_params.trading_mode if _current_params else TradingMode.NORMAL.value
|
||||
|
||||
# Crisis 체크 (최우선) — 진짜 위기만
|
||||
if atr_percentile > 95 or recent_5d_return < -8.0 or mdd_current < -15.0:
|
||||
# Crisis 체크 (최우선) — CLAUDE.md 섹션 8.5 정합
|
||||
if atr_percentile > 90 or recent_5d_return < -5.0 or mdd_current < -10.0:
|
||||
return TradingMode.CRISIS.value
|
||||
# Crisis 해제: 더 완화된 조건 (히스테리시스)
|
||||
if prev_mode == TradingMode.CRISIS.value:
|
||||
if atr_percentile > 85 or recent_5d_return < -5.0 or mdd_current < -10.0:
|
||||
if atr_percentile > 80 or recent_5d_return < -3.0 or mdd_current < -8.0:
|
||||
return TradingMode.CRISIS.value # 아직 위기 유지
|
||||
|
||||
# Defensive 체크 — 일반적인 조정장
|
||||
if atr_percentile > 80 or recent_5d_return < -5.0:
|
||||
# Defensive 체크 — CLAUDE.md 섹션 8.5 정합
|
||||
if atr_percentile > 70 or recent_5d_return < -3.0:
|
||||
return TradingMode.DEFENSIVE.value
|
||||
# Defensive 해제: ATR<70, 5일수익>-3% (히스테리시스)
|
||||
# Defensive 해제: ATR<60, 5일수익>-1% (히스테리시스)
|
||||
if prev_mode == TradingMode.DEFENSIVE.value:
|
||||
if atr_percentile > 70 or recent_5d_return < -3.0:
|
||||
if atr_percentile > 60 or recent_5d_return < -1.0:
|
||||
return TradingMode.DEFENSIVE.value # 아직 방어 유지
|
||||
|
||||
# Aggressive 체크 (모든 조건 AND)
|
||||
|
||||
870
backend/app/services/ai_strategy_service.py
Normal file
870
backend/app/services/ai_strategy_service.py
Normal file
@@ -0,0 +1,870 @@
|
||||
"""AI 전략 서비스 (ai_strategy_service.py)
|
||||
|
||||
장 전/장중/장 후 AI 분석을 관리하는 통합 서비스.
|
||||
MCP 서버의 BATCH_STOCK_ANALYSIS, NEWS_SENTIMENT, DAILY_REVIEW를 호출.
|
||||
|
||||
[호출 타이밍]
|
||||
08:30 pre_market_analysis() -- Sonnet 배치 분석 -> Redis 캐시
|
||||
08:50 select_daytrading_candidates() -- Haiku 데이트레이딩 종목 선정
|
||||
10분 주기 monitor_news() -- Haiku 뉴스 감시 -> 악재 시 Sonnet 긴급 분석
|
||||
15:40 post_market_review() -- Sonnet 당일 복기 -> Telegram 발송
|
||||
|
||||
아키텍처 준수:
|
||||
- DB 쓰기 금지 (read-only SELECT만 수행, CLAUDE.md 3.1 예외 조항 동일)
|
||||
- 결과는 Redis 캐시 또는 반환값으로만 전달
|
||||
- MCP 호출은 mcp.py의 httpx 싱글톤 클라이언트 패턴 재사용
|
||||
- JSON 직렬화: _dumps() 사용 (CLAUDE.md 4.2)
|
||||
- import는 함수 내부에서 수행 (순환 import 방지)
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Redis 캐시 키 & TTL 상수 ────────────────────────────────────────────────
|
||||
CACHE_KEY_PRE_MARKET = "ai:pre_market:latest"
|
||||
CACHE_KEY_DT_CANDIDATES = "ai:daytrading_candidates"
|
||||
CACHE_KEY_NEWS_HASH_PREFIX = "ai:news_hash:"
|
||||
|
||||
TTL_PRE_MARKET = 8 * 3600 # 8시간
|
||||
TTL_DT_CANDIDATES = 6 * 3600 # 6시간
|
||||
TTL_NEWS_HASH = 24 * 3600 # 24시간
|
||||
|
||||
# ── 배치 사이즈 상수 ────────────────────────────────────────────────────────
|
||||
_PRE_MARKET_TOP_N = 50 # 장 전 분석 대상 종목 수
|
||||
_BATCH_SIZE = 10 # MCP 배치 요청 단위
|
||||
_NEWS_PER_STOCK = 3 # 종목당 크롤링 뉴스 건수
|
||||
_DT_CANDIDATE_TOP_N = 20 # 데이트레이딩 후보 종목 수
|
||||
_DT_CANDIDATE_BATCH = 10 # 데이트레이딩 MCP 배치 단위
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# A. 장 전 배치 분석
|
||||
# =============================================================================
|
||||
|
||||
async def pre_market_analysis() -> dict:
|
||||
"""장 전 AI 배치 분석 (08:30 호출).
|
||||
|
||||
1. DB에서 stock_analysis 최근 분석 상위 50종목 조회 (score 내림차순)
|
||||
2. 각 종목의 뉴스 제목 3건 조회
|
||||
3. 10종목씩 MCP BATCH_STOCK_ANALYSIS 요청 (5회)
|
||||
4. Redis "ai:pre_market:latest" 캐시 (TTL 8시간)
|
||||
5. 적합 종목 리스트 반환
|
||||
|
||||
Returns:
|
||||
{"total_analyzed": int, "suitable_stocks": [...], "timestamp": str}
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.redis import setex_json
|
||||
from app.core.json_helpers import _dumps
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"total_analyzed": 0,
|
||||
"suitable_stocks": [],
|
||||
"errors": [],
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# ── 1) DB에서 최근 분석 상위 50종목 조회 ──
|
||||
top_stocks: List[Dict[str, Any]] = []
|
||||
try:
|
||||
from sqlalchemy import select, desc
|
||||
from app.models.models import StockAnalysis
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = (
|
||||
select(StockAnalysis)
|
||||
.where(StockAnalysis.score.isnot(None))
|
||||
.order_by(desc(StockAnalysis.score), desc(StockAnalysis.analysis_date))
|
||||
.limit(_PRE_MARKET_TOP_N)
|
||||
)
|
||||
rows = await db.execute(stmt)
|
||||
analyses = rows.scalars().all()
|
||||
|
||||
for a in analyses:
|
||||
top_stocks.append({
|
||||
"stock_code": a.stock_code,
|
||||
"stock_name": a.stock_name,
|
||||
"score": a.score,
|
||||
"rsi": float(a.rsi) if a.rsi else None,
|
||||
"current_price": a.current_price,
|
||||
"analysis_date": str(a.analysis_date),
|
||||
"signals": a.signals,
|
||||
})
|
||||
|
||||
logger.info("[AI-Pre] DB에서 %d개 종목 조회 완료", len(top_stocks))
|
||||
except Exception as e:
|
||||
logger.error("[AI-Pre] DB 조회 실패: %s", e)
|
||||
result["errors"].append(f"db_query_failed: {e}")
|
||||
return result
|
||||
|
||||
if not top_stocks:
|
||||
logger.warning("[AI-Pre] 분석 대상 종목 없음")
|
||||
return result
|
||||
|
||||
# ── 2) 종목별 뉴스 제목 3건 조회 ──
|
||||
for stock in top_stocks:
|
||||
stock["recent_news"] = await _fetch_stock_news_titles(
|
||||
stock["stock_code"], limit=_NEWS_PER_STOCK
|
||||
)
|
||||
|
||||
# ── 3) 10종목씩 묶어서 MCP BATCH_STOCK_ANALYSIS 요청 ──
|
||||
all_results: List[Dict[str, Any]] = []
|
||||
batches = [
|
||||
top_stocks[i : i + _BATCH_SIZE]
|
||||
for i in range(0, len(top_stocks), _BATCH_SIZE)
|
||||
]
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
batch_result = await _call_mcp_batch_analysis(
|
||||
batch, context="pre_market"
|
||||
)
|
||||
if batch_result:
|
||||
all_results.extend(batch_result)
|
||||
else:
|
||||
result["errors"].append(f"batch_{batch_idx}_failed")
|
||||
|
||||
# ── 4) 적합 종목 필터링 ──
|
||||
suitable: List[Dict[str, Any]] = []
|
||||
for item in all_results:
|
||||
ai_score = item.get("ai_score", 0)
|
||||
recommendation = str(item.get("recommendation", "")).upper()
|
||||
if ai_score >= settings.MCP_ANALYSIS_MIN_SCORE or recommendation in (
|
||||
"BUY",
|
||||
"STRONG_BUY",
|
||||
):
|
||||
suitable.append(item)
|
||||
|
||||
result["total_analyzed"] = len(all_results)
|
||||
result["suitable_stocks"] = suitable
|
||||
logger.info(
|
||||
"[AI-Pre] 분석 완료: %d개 분석, %d개 적합",
|
||||
len(all_results),
|
||||
len(suitable),
|
||||
)
|
||||
|
||||
# ── 5) Redis 캐시 ──
|
||||
try:
|
||||
await setex_json(CACHE_KEY_PRE_MARKET, TTL_PRE_MARKET, result)
|
||||
logger.info("[AI-Pre] Redis 캐시 저장 완료 (TTL=%ds)", TTL_PRE_MARKET)
|
||||
except Exception as e:
|
||||
logger.warning("[AI-Pre] Redis 캐시 저장 실패: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# B. 데이트레이딩 종목 선정
|
||||
# =============================================================================
|
||||
|
||||
async def select_daytrading_candidates() -> list[dict]:
|
||||
"""데이트레이딩 후보 종목 선정 (08:50 호출).
|
||||
|
||||
1. 네이버 시가총액 상위 종목 조회
|
||||
2. 가격 필터 (5,000원 이상) 적용
|
||||
3. 상위 20종목을 MCP BATCH_STOCK_ANALYSIS로 전송 (2회 배치)
|
||||
4. Redis "ai:daytrading_candidates" 캐시 (TTL 6시간)
|
||||
5. daytrading watchlist에 자동 등록
|
||||
|
||||
Returns:
|
||||
[{"stock_code", "stock_name", "ai_score", ...}, ...]
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.core.redis import setex_json
|
||||
from app.services.naver_finance import get_naver_stock_list
|
||||
|
||||
candidates: List[Dict[str, Any]] = []
|
||||
|
||||
# ── 1) 네이버 시가총액 상위 종목 조회 ──
|
||||
try:
|
||||
stocks = await get_naver_stock_list(
|
||||
max_pages_per_market=5,
|
||||
min_price=settings.MIN_PRICE_KRW,
|
||||
limit=100,
|
||||
)
|
||||
logger.info("[AI-DT] 네이버 종목 %d개 수집", len(stocks))
|
||||
except Exception as e:
|
||||
logger.error("[AI-DT] 네이버 종목 조회 실패: %s", e)
|
||||
return candidates
|
||||
|
||||
if not stocks:
|
||||
logger.warning("[AI-DT] 조회된 종목 없음")
|
||||
return candidates
|
||||
|
||||
# ── 2) 상위 N개 선정 ──
|
||||
stock_list = [
|
||||
{"stock_code": code, "stock_name": name}
|
||||
for code, name in stocks[:_DT_CANDIDATE_TOP_N]
|
||||
]
|
||||
|
||||
# ── 3) 종목별 뉴스 수집 + MCP 배치 분석 (데이트레이딩 관점) ──
|
||||
for stock in stock_list:
|
||||
stock["recent_news"] = await _fetch_stock_news_titles(
|
||||
stock["stock_code"], limit=_NEWS_PER_STOCK
|
||||
)
|
||||
|
||||
all_results: List[Dict[str, Any]] = []
|
||||
batches = [
|
||||
stock_list[i : i + _DT_CANDIDATE_BATCH]
|
||||
for i in range(0, len(stock_list), _DT_CANDIDATE_BATCH)
|
||||
]
|
||||
|
||||
for batch_idx, batch in enumerate(batches):
|
||||
batch_result = await _call_mcp_batch_analysis(
|
||||
batch, context="daytrading"
|
||||
)
|
||||
if batch_result:
|
||||
all_results.extend(batch_result)
|
||||
|
||||
# ── 4) 적합 종목 필터링 (데이트레이딩 기준) ──
|
||||
for item in all_results:
|
||||
ai_score = item.get("ai_score", 0)
|
||||
dt_suitable = item.get("daytrading_suitable", False)
|
||||
recommendation = str(item.get("recommendation", "")).upper()
|
||||
if dt_suitable or ai_score >= settings.MCP_ANALYSIS_MIN_SCORE or recommendation in (
|
||||
"BUY",
|
||||
"STRONG_BUY",
|
||||
):
|
||||
candidates.append(item)
|
||||
|
||||
logger.info(
|
||||
"[AI-DT] 분석 완료: %d개 분석, %d개 후보",
|
||||
len(all_results),
|
||||
len(candidates),
|
||||
)
|
||||
|
||||
# ── 5) Redis 캐시 ──
|
||||
try:
|
||||
await setex_json(CACHE_KEY_DT_CANDIDATES, TTL_DT_CANDIDATES, candidates)
|
||||
except Exception as e:
|
||||
logger.warning("[AI-DT] Redis 캐시 저장 실패: %s", e)
|
||||
|
||||
# ── 6) daytrading watchlist에 자동 등록 ──
|
||||
if candidates:
|
||||
try:
|
||||
from app.services.daytrading.entry_service import set_watchlist
|
||||
|
||||
watchlist_items = [
|
||||
{"code": c["stock_code"], "name": c.get("stock_name", c["stock_code"])}
|
||||
for c in candidates
|
||||
]
|
||||
set_watchlist(watchlist_items)
|
||||
logger.info("[AI-DT] daytrading watchlist %d종목 등록", len(watchlist_items))
|
||||
except Exception as e:
|
||||
logger.warning("[AI-DT] watchlist 등록 실패: %s", e)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# C. 장중 뉴스 모니터링
|
||||
# =============================================================================
|
||||
|
||||
async def monitor_news() -> list[dict]:
|
||||
"""장중 뉴스 모니터링 (10분 주기 호출).
|
||||
|
||||
1. 보유 종목 + 데이트레이딩 포지션 조회
|
||||
2. 종목별 최신 뉴스 3건 크롤링
|
||||
3. 이전 뉴스 해시 비교 -> 새 뉴스만 MCP NEWS_SENTIMENT 요청
|
||||
4. NEGATIVE + HIGH severity -> Telegram 알림
|
||||
5. 뉴스 해시 Redis 업데이트
|
||||
|
||||
Returns:
|
||||
[{"stock_code", "stock_name", "sentiment", "severity", "titles": [...], "action": str}, ...]
|
||||
-- sentiment="NEGATIVE" & severity="HIGH"인 종목만 포함
|
||||
"""
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.redis import get_json, setex_json
|
||||
|
||||
alerts: List[Dict[str, Any]] = []
|
||||
|
||||
# ── 1) 보유 종목 + 데이트레이딩 포지션 조회 ──
|
||||
monitored_stocks: Dict[str, str] = {} # code -> name
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
from app.models.models import Portfolio
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = select(Portfolio).where(Portfolio.quantity > 0)
|
||||
rows = await db.execute(stmt)
|
||||
for p in rows.scalars().all():
|
||||
monitored_stocks[p.stock_code] = p.stock_name or p.stock_code
|
||||
except Exception as e:
|
||||
logger.error("[AI-News] 포트폴리오 조회 실패: %s", e)
|
||||
|
||||
# 데이트레이딩 포지션 추가
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
for code, pos in day_trading_state.positions.items():
|
||||
if code not in monitored_stocks:
|
||||
monitored_stocks[code] = pos.get("stock_name", code)
|
||||
except Exception as e:
|
||||
logger.warning("[AI-News] 데이트레이딩 포지션 조회 실패: %s", e)
|
||||
|
||||
if not monitored_stocks:
|
||||
logger.debug("[AI-News] 모니터링 대상 종목 없음")
|
||||
return alerts
|
||||
|
||||
logger.info("[AI-News] %d개 종목 뉴스 모니터링 시작", len(monitored_stocks))
|
||||
|
||||
# ── 2) 종목별 뉴스 크롤링 + 해시 비교 ──
|
||||
for stock_code, stock_name in monitored_stocks.items():
|
||||
try:
|
||||
news_titles = await _fetch_stock_news_titles(
|
||||
stock_code, limit=_NEWS_PER_STOCK
|
||||
)
|
||||
if not news_titles:
|
||||
continue
|
||||
|
||||
# 현재 뉴스 해시 계산
|
||||
current_hash = _compute_news_hash(news_titles)
|
||||
|
||||
# 이전 해시와 비교
|
||||
cache_key = f"{CACHE_KEY_NEWS_HASH_PREFIX}{stock_code}"
|
||||
prev_hash = await get_json(cache_key)
|
||||
|
||||
if prev_hash == current_hash:
|
||||
# 뉴스 변경 없음 -- skip
|
||||
continue
|
||||
|
||||
# ── 3) 새 뉴스 감지 -> MCP NEWS_SENTIMENT 요청 ──
|
||||
sentiment_result = await _call_mcp_news_sentiment(
|
||||
stock_code, stock_name, news_titles
|
||||
)
|
||||
|
||||
# ── 4) 뉴스 해시 업데이트 ──
|
||||
try:
|
||||
await setex_json(cache_key, TTL_NEWS_HASH, current_hash)
|
||||
except Exception as e:
|
||||
logger.warning("[AI-News] 해시 캐시 저장 실패 %s: %s", stock_code, e)
|
||||
|
||||
if not sentiment_result:
|
||||
continue
|
||||
|
||||
sentiment = str(sentiment_result.get("sentiment", "")).upper()
|
||||
severity = str(sentiment_result.get("severity", "")).upper()
|
||||
|
||||
# ── 5) 악재 알림 ──
|
||||
if sentiment == "NEGATIVE" and severity == "HIGH":
|
||||
alert_item = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"sentiment": sentiment,
|
||||
"severity": severity,
|
||||
"titles": news_titles,
|
||||
"analysis": sentiment_result.get("analysis", ""),
|
||||
"action": sentiment_result.get("recommended_action", "review"),
|
||||
}
|
||||
alerts.append(alert_item)
|
||||
|
||||
# Telegram 알림
|
||||
await _send_news_alert_telegram(alert_item)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[AI-News] 종목 %s 뉴스 처리 오류: %s", stock_code, e)
|
||||
|
||||
if alerts:
|
||||
logger.warning("[AI-News] %d개 악재 알림 발생", len(alerts))
|
||||
else:
|
||||
logger.info("[AI-News] 뉴스 모니터링 완료 (악재 없음)")
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# D. 장 후 리뷰
|
||||
# =============================================================================
|
||||
|
||||
async def post_market_review() -> dict:
|
||||
"""장 후 AI 리뷰 (15:40 호출).
|
||||
|
||||
1. DB에서 당일 TradeHistory (SELL) 조회
|
||||
2. 승/패, PnL, 전략별 통계 집계
|
||||
3. 시장 요약 (코스피 변동률, 시장 국면)
|
||||
4. MCP DAILY_REVIEW 요청
|
||||
5. Telegram 리뷰 결과 발송
|
||||
6. 결과 반환
|
||||
|
||||
Returns:
|
||||
{"date", "summary", "trades", "pnl", "ai_review", ...}
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal
|
||||
|
||||
today = date.today()
|
||||
result: Dict[str, Any] = {
|
||||
"date": str(today),
|
||||
"trades": {"total": 0, "wins": 0, "losses": 0},
|
||||
"pnl": {"total": 0.0, "by_strategy": {}},
|
||||
"market_summary": {},
|
||||
"ai_review": None,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# ── 1) 당일 매도 기록 조회 ──
|
||||
sell_trades: List[Dict[str, Any]] = []
|
||||
try:
|
||||
from sqlalchemy import select, and_, func, cast, Date
|
||||
from app.models.models import TradeHistory
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
stmt = (
|
||||
select(TradeHistory)
|
||||
.where(
|
||||
and_(
|
||||
TradeHistory.order_type == "SELL",
|
||||
cast(TradeHistory.created_at, Date) == today,
|
||||
)
|
||||
)
|
||||
.order_by(TradeHistory.created_at)
|
||||
)
|
||||
rows = await db.execute(stmt)
|
||||
trades = rows.scalars().all()
|
||||
|
||||
for t in trades:
|
||||
sell_trades.append({
|
||||
"stock_code": t.stock_code,
|
||||
"stock_name": t.stock_name or t.stock_code,
|
||||
"quantity": t.executed_quantity or t.order_quantity,
|
||||
"price": float(t.executed_price) if t.executed_price else 0,
|
||||
"profit_loss": float(t.net_profit_loss) if t.net_profit_loss else 0,
|
||||
"profit_loss_rate": float(t.profit_loss_rate) if t.profit_loss_rate else 0,
|
||||
"strategy_type": t.strategy_type or "swing",
|
||||
"exit_reason": t.exit_reason or "",
|
||||
})
|
||||
|
||||
logger.info("[AI-Review] 당일 매도 %d건 조회", len(sell_trades))
|
||||
except Exception as e:
|
||||
logger.error("[AI-Review] 매도 이력 조회 실패: %s", e)
|
||||
|
||||
# ── 2) 통계 집계 ──
|
||||
total_pnl = 0.0
|
||||
wins = 0
|
||||
losses = 0
|
||||
by_strategy: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for t in sell_trades:
|
||||
pnl = t["profit_loss"]
|
||||
total_pnl += pnl
|
||||
if pnl >= 0:
|
||||
wins += 1
|
||||
else:
|
||||
losses += 1
|
||||
|
||||
st = t["strategy_type"]
|
||||
if st not in by_strategy:
|
||||
by_strategy[st] = {"count": 0, "pnl": 0.0, "wins": 0, "losses": 0}
|
||||
by_strategy[st]["count"] += 1
|
||||
by_strategy[st]["pnl"] += pnl
|
||||
if pnl >= 0:
|
||||
by_strategy[st]["wins"] += 1
|
||||
else:
|
||||
by_strategy[st]["losses"] += 1
|
||||
|
||||
result["trades"] = {
|
||||
"total": len(sell_trades),
|
||||
"wins": wins,
|
||||
"losses": losses,
|
||||
"win_rate": round(wins / len(sell_trades) * 100, 1) if sell_trades else 0.0,
|
||||
}
|
||||
result["pnl"] = {
|
||||
"total": round(total_pnl, 2),
|
||||
"by_strategy": {
|
||||
k: {**v, "pnl": round(v["pnl"], 2)} for k, v in by_strategy.items()
|
||||
},
|
||||
}
|
||||
|
||||
# ── 3) 시장 요약 ──
|
||||
try:
|
||||
from app.services.market_analyzer import check_market_regime
|
||||
|
||||
regime_info = await check_market_regime()
|
||||
result["market_summary"] = {
|
||||
"regime": regime_info.get("regime", "UNKNOWN"),
|
||||
"kospi_change_pct": regime_info.get("kospi_change_pct"),
|
||||
"description": regime_info.get("description", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("[AI-Review] 시장 국면 조회 실패: %s", e)
|
||||
result["market_summary"] = {"regime": "UNKNOWN"}
|
||||
|
||||
# ── 4) MCP DAILY_REVIEW 요청 ──
|
||||
try:
|
||||
review_data = {
|
||||
"date": str(today),
|
||||
"trades": sell_trades,
|
||||
"statistics": result["trades"],
|
||||
"pnl": result["pnl"],
|
||||
"market_summary": result["market_summary"],
|
||||
}
|
||||
|
||||
ai_review = await _call_mcp_daily_review(review_data)
|
||||
result["ai_review"] = ai_review
|
||||
except Exception as e:
|
||||
logger.error("[AI-Review] MCP DAILY_REVIEW 호출 실패: %s", e)
|
||||
|
||||
# ── 5) Telegram 발송 ──
|
||||
await _send_review_telegram(result)
|
||||
|
||||
logger.info(
|
||||
"[AI-Review] 리뷰 완료: %d건 매매, PnL %.0f원, 승률 %.1f%%",
|
||||
result["trades"]["total"],
|
||||
total_pnl,
|
||||
result["trades"]["win_rate"],
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 내부 헬퍼 함수들
|
||||
# =============================================================================
|
||||
|
||||
async def _fetch_stock_news_titles(stock_code: str, limit: int = 3) -> list[str]:
|
||||
"""종목별 뉴스 제목을 네이버에서 크롤링하여 반환.
|
||||
|
||||
news_sentiment.collect_stock_news()를 재활용하되 제목만 추출.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드 (예: "005930")
|
||||
limit: 최대 뉴스 건수
|
||||
|
||||
Returns:
|
||||
["뉴스 제목 1", "뉴스 제목 2", ...]
|
||||
"""
|
||||
try:
|
||||
from app.services.news_sentiment import collect_stock_news
|
||||
|
||||
articles = await collect_stock_news(stock_code, limit=limit)
|
||||
return [a.get("title", "") for a in articles if a.get("title")]
|
||||
except Exception as e:
|
||||
logger.debug("[AI] 뉴스 크롤링 실패 %s: %s", stock_code, e)
|
||||
return []
|
||||
|
||||
|
||||
def _compute_news_hash(titles: list[str]) -> str:
|
||||
"""뉴스 제목 리스트의 해시를 계산하여 변경 감지에 사용.
|
||||
|
||||
Args:
|
||||
titles: 뉴스 제목 리스트
|
||||
|
||||
Returns:
|
||||
SHA-256 해시 문자열 (hex, 16자)
|
||||
"""
|
||||
combined = "|".join(sorted(titles))
|
||||
return hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
async def _get_mcp_client():
|
||||
"""MCP httpx 싱글톤 클라이언트를 반환. 초기화 전이면 None.
|
||||
|
||||
mcp.py의 get_http_client() 패턴과 동일하지만,
|
||||
이 서비스에서는 RuntimeError 대신 None을 반환하여
|
||||
호출자가 graceful하게 처리할 수 있도록 함.
|
||||
"""
|
||||
try:
|
||||
from app.api.endpoints.mcp import get_http_client
|
||||
return get_http_client()
|
||||
except RuntimeError:
|
||||
logger.warning("[AI] MCP httpx client 미초기화")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("[AI] MCP client 획득 실패: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def _call_mcp_batch_analysis(
|
||||
stocks: list[dict],
|
||||
context: str = "pre_market",
|
||||
) -> Optional[list[dict]]:
|
||||
"""MCP 서버에 BATCH_STOCK_ANALYSIS 요청.
|
||||
|
||||
Args:
|
||||
stocks: 종목 리스트 [{"stock_code", "stock_name", "score", "recent_news", ...}]
|
||||
context: "pre_market" 또는 "daytrading" (프롬프트 관점 결정)
|
||||
|
||||
Returns:
|
||||
[{"stock_code", "stock_name", "ai_score", "recommendation", ...}] 또는 None
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
client = await _get_mcp_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
# MCP 요청 페이로드 구성
|
||||
batch_payload = []
|
||||
for s in stocks:
|
||||
item = {
|
||||
"stock_code": s.get("stock_code", ""),
|
||||
"stock_name": s.get("stock_name", ""),
|
||||
"recent_news": s.get("recent_news", []),
|
||||
}
|
||||
# pre_market 에서는 기존 분석 점수/지표 포함
|
||||
if context == "pre_market":
|
||||
item["current_score"] = s.get("score")
|
||||
item["rsi"] = s.get("rsi")
|
||||
item["current_price"] = s.get("current_price")
|
||||
item["signals"] = s.get("signals")
|
||||
batch_payload.append(item)
|
||||
|
||||
request_data = {
|
||||
"analysis_type": "BATCH_STOCK_ANALYSIS",
|
||||
"data": {
|
||||
"stocks": batch_payload,
|
||||
"context": context,
|
||||
},
|
||||
}
|
||||
|
||||
# 데이트레이딩 관점 추가
|
||||
if context == "daytrading":
|
||||
request_data["data"]["evaluation_focus"] = "daytrading_suitability"
|
||||
request_data["data"]["criteria"] = (
|
||||
"volatility, volume, intraday_range, liquidity, "
|
||||
"ORB/VWAP/RSI_BB signal potential"
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{settings.MCP_SERVER_URL}/analyze",
|
||||
json=request_data,
|
||||
timeout=60.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# MCP 응답 구조: {"result": {...}} 또는 {"result": [{...}, ...]}
|
||||
payload = data.get("result", data)
|
||||
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
elif isinstance(payload, dict):
|
||||
# 단일 결과가 dict로 올 경우 stocks 키 확인
|
||||
return payload.get("stocks", payload.get("analyses", [payload]))
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
"[AI] MCP BATCH_STOCK_ANALYSIS HTTP %d: %s",
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("[AI] MCP BATCH_STOCK_ANALYSIS 호출 오류: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def _call_mcp_news_sentiment(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
news_titles: list[str],
|
||||
) -> Optional[dict]:
|
||||
"""MCP 서버에 NEWS_SENTIMENT 요청.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
stock_name: 종목명
|
||||
news_titles: 뉴스 제목 리스트
|
||||
|
||||
Returns:
|
||||
{"sentiment": "POSITIVE"|"NEGATIVE"|"NEUTRAL",
|
||||
"severity": "HIGH"|"MEDIUM"|"LOW",
|
||||
"analysis": str,
|
||||
"recommended_action": str}
|
||||
또는 None
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
client = await _get_mcp_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
request_data = {
|
||||
"analysis_type": "NEWS_SENTIMENT",
|
||||
"data": {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"news_titles": news_titles,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{settings.MCP_SERVER_URL}/analyze",
|
||||
json=request_data,
|
||||
timeout=30.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("result", data)
|
||||
else:
|
||||
logger.warning(
|
||||
"[AI] MCP NEWS_SENTIMENT HTTP %d (%s): %s",
|
||||
resp.status_code,
|
||||
stock_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("[AI] MCP NEWS_SENTIMENT 호출 오류 (%s): %s", stock_code, e)
|
||||
return None
|
||||
|
||||
|
||||
async def _call_mcp_daily_review(review_data: dict) -> Optional[dict]:
|
||||
"""MCP 서버에 DAILY_REVIEW 요청.
|
||||
|
||||
Args:
|
||||
review_data: 당일 매매 요약 데이터
|
||||
|
||||
Returns:
|
||||
{"review_text": str, "lessons": [...], "suggestions": [...], "grade": str}
|
||||
또는 None
|
||||
"""
|
||||
from app.core.config import settings
|
||||
|
||||
client = await _get_mcp_client()
|
||||
if client is None:
|
||||
return None
|
||||
|
||||
request_data = {
|
||||
"analysis_type": "DAILY_REVIEW",
|
||||
"data": review_data,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{settings.MCP_SERVER_URL}/analyze",
|
||||
json=request_data,
|
||||
timeout=60.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("result", data)
|
||||
else:
|
||||
logger.warning(
|
||||
"[AI] MCP DAILY_REVIEW HTTP %d: %s",
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("[AI] MCP DAILY_REVIEW 호출 오류: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def _send_news_alert_telegram(alert: dict) -> None:
|
||||
"""악재 뉴스 알림을 Telegram으로 발송.
|
||||
|
||||
Args:
|
||||
alert: monitor_news()가 생성한 알림 항목
|
||||
"""
|
||||
try:
|
||||
from app.services.telegram_notifier import telegram_notifier, _escape_markdown_v2
|
||||
|
||||
esc = _escape_markdown_v2
|
||||
stock_code = alert.get("stock_code", "")
|
||||
stock_name = alert.get("stock_name", stock_code)
|
||||
titles = alert.get("titles", [])
|
||||
analysis = alert.get("analysis", "")
|
||||
|
||||
lines = [
|
||||
f"\U0001F6A8 *{esc('악재 뉴스 감지')}*",
|
||||
f"",
|
||||
f"\U0001F4C8 {esc(stock_name)} \\({esc(stock_code)}\\)",
|
||||
f"\U0001F4F0 {esc('뉴스')}:",
|
||||
]
|
||||
for i, title in enumerate(titles[:3], 1):
|
||||
lines.append(f" {esc(str(i))}\\. {esc(title)}")
|
||||
|
||||
if analysis:
|
||||
lines.append(f"")
|
||||
lines.append(f"\U0001F4DD {esc('분석')}: {esc(str(analysis)[:200])}")
|
||||
|
||||
action = alert.get("action", "review")
|
||||
lines.append(f"")
|
||||
lines.append(f"\U000026A0 {esc('권장 조치')}: {esc(action)}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
await telegram_notifier.send_message(text)
|
||||
except Exception as e:
|
||||
logger.warning("[AI-News] Telegram 알림 발송 실패: %s", e)
|
||||
|
||||
|
||||
async def _send_review_telegram(review: dict) -> None:
|
||||
"""장 후 리뷰를 Telegram으로 발송.
|
||||
|
||||
Args:
|
||||
review: post_market_review()가 생성한 결과
|
||||
"""
|
||||
try:
|
||||
from app.services.telegram_notifier import telegram_notifier, _escape_markdown_v2
|
||||
|
||||
esc = _escape_markdown_v2
|
||||
trades_info = review.get("trades", {})
|
||||
pnl_info = review.get("pnl", {})
|
||||
total_pnl = pnl_info.get("total", 0)
|
||||
total_count = trades_info.get("total", 0)
|
||||
wins = trades_info.get("wins", 0)
|
||||
losses = trades_info.get("losses", 0)
|
||||
win_rate = trades_info.get("win_rate", 0.0)
|
||||
|
||||
pnl_sign = "+" if total_pnl >= 0 else ""
|
||||
pnl_emoji = "\U0001F4C8" if total_pnl >= 0 else "\U0001F4C9"
|
||||
|
||||
lines = [
|
||||
f"\U0001F4CA *{esc('AI 일일 리뷰')}*",
|
||||
f"\U0001F4C5 {esc(review.get('date', ''))}",
|
||||
f"",
|
||||
f"{pnl_emoji} {esc('당일 PnL')}: {esc(f'{pnl_sign}{total_pnl:,.0f}')}원",
|
||||
f"\U0001F3AF {esc('매매')}: {esc(str(total_count))}건 "
|
||||
f"\\({esc(f'W:{wins}')} / {esc(f'L:{losses}')}\\)",
|
||||
f"\U0001F4AF {esc('승률')}: {esc(f'{win_rate:.1f}%')}",
|
||||
]
|
||||
|
||||
# 전략별 PnL
|
||||
by_strategy = pnl_info.get("by_strategy", {})
|
||||
if by_strategy:
|
||||
lines.append(f"")
|
||||
lines.append(f"\U0001F4CB {esc('전략별')}:")
|
||||
for strategy, stats in by_strategy.items():
|
||||
st_pnl = stats.get("pnl", 0)
|
||||
st_count = stats.get("count", 0)
|
||||
st_sign = "+" if st_pnl >= 0 else ""
|
||||
lines.append(
|
||||
f" {esc(strategy)}: {esc(f'{st_sign}{st_pnl:,.0f}')}원 "
|
||||
f"\\({esc(str(st_count))}건\\)"
|
||||
)
|
||||
|
||||
# AI 리뷰 요약 (있으면)
|
||||
ai_review = review.get("ai_review")
|
||||
if ai_review:
|
||||
review_text = ai_review.get("review_text", "")
|
||||
grade = ai_review.get("grade", "")
|
||||
if grade:
|
||||
lines.append(f"")
|
||||
lines.append(f"\U0001F3C6 {esc('AI 평가')}: {esc(grade)}")
|
||||
if review_text:
|
||||
# 최대 300자 (Telegram 메시지 길이 제한 고려)
|
||||
truncated = str(review_text)[:300]
|
||||
lines.append(f"\U0001F4DD {esc(truncated)}")
|
||||
|
||||
lessons = ai_review.get("lessons", [])
|
||||
if lessons:
|
||||
lines.append(f"")
|
||||
lines.append(f"\U0001F4A1 {esc('교훈')}:")
|
||||
for lesson in lessons[:3]:
|
||||
lines.append(f" \\- {esc(str(lesson))}")
|
||||
|
||||
market_info = review.get("market_summary", {})
|
||||
regime = market_info.get("regime", "")
|
||||
if regime:
|
||||
lines.append(f"")
|
||||
lines.append(f"\U0001F30D {esc('시장 국면')}: {esc(regime)}")
|
||||
|
||||
text = "\n".join(lines)
|
||||
await telegram_notifier.send_message(text)
|
||||
except Exception as e:
|
||||
logger.warning("[AI-Review] Telegram 리뷰 발송 실패: %s", e)
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from collections import defaultdict, deque
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
@@ -30,6 +32,34 @@ from app.services.feature_engineering import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 슬리피지 모델 (국내 주식 0.1%) ──────────────────────────────────────────────
|
||||
# QA 검증 항목: 슬리피지 미반영 시 백테스트 수익률이 실전 대비 과대 추정됨
|
||||
# kr_backtest_engine.py와 동일한 패턴 적용
|
||||
KR_SLIPPAGE_PCT = 0.001 # +/-0.1% (국내 주식 평균 슬리피지)
|
||||
|
||||
|
||||
def _apply_buy_slippage(price: float, seed: Optional[int] = None) -> float:
|
||||
"""매수 시 슬리피지: 시장가 주문 시 약간 높은 가격에 체결
|
||||
|
||||
매수 시에는 호가 스프레드와 시장 충격으로 인해 기대 가격보다
|
||||
0~0.1% 높은 가격에 체결되는 현실을 반영합니다.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
slip = rng.uniform(0, KR_SLIPPAGE_PCT)
|
||||
return price * (1 + slip)
|
||||
|
||||
|
||||
def _apply_sell_slippage(price: float, seed: Optional[int] = None) -> float:
|
||||
"""매도 시 슬리피지: 시장가 주문 시 약간 낮은 가격에 체결
|
||||
|
||||
매도 시에는 호가 스프레드와 시장 충격으로 인해 기대 가격보다
|
||||
0~0.1% 낮은 가격에 체결되는 현실을 반영합니다.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
slip = rng.uniform(0, KR_SLIPPAGE_PCT)
|
||||
return price * (1 - slip)
|
||||
|
||||
|
||||
# ── 데이터 클래스 ────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -137,10 +167,10 @@ class BacktestEngine:
|
||||
StockDailyPrice.trade_date <= end_date
|
||||
)
|
||||
).order_by(StockDailyPrice.trade_date)
|
||||
|
||||
|
||||
db_result = await session.execute(stmt)
|
||||
rows = db_result.scalars().all()
|
||||
|
||||
|
||||
if rows:
|
||||
df = db_data_to_dataframe([{
|
||||
"trade_date": r.trade_date,
|
||||
@@ -151,7 +181,7 @@ class BacktestEngine:
|
||||
"volume": float(r.volume),
|
||||
} for r in rows])
|
||||
result[code] = df
|
||||
|
||||
|
||||
return result
|
||||
|
||||
def _calculate_commission(self, amount: float, is_sell: bool, config: BacktestConfig) -> float:
|
||||
@@ -170,41 +200,44 @@ class BacktestEngine:
|
||||
reason: str,
|
||||
config: BacktestConfig
|
||||
):
|
||||
"""매수 실행"""
|
||||
quantity = int(amount / price)
|
||||
"""매수 실행 (슬리피지 적용: 실제 체결가는 신호가보다 높음)"""
|
||||
# 슬리피지 적용: 매수 시 약간 높은 가격에 체결
|
||||
exec_price = _apply_buy_slippage(price)
|
||||
|
||||
quantity = int(amount / exec_price)
|
||||
if quantity == 0:
|
||||
return
|
||||
|
||||
total_cost = price * quantity
|
||||
|
||||
total_cost = exec_price * quantity
|
||||
commission = self._calculate_commission(total_cost, False, config)
|
||||
total_amount = total_cost + commission
|
||||
|
||||
|
||||
if total_amount > self.cash:
|
||||
return
|
||||
|
||||
|
||||
self.cash -= total_amount
|
||||
|
||||
|
||||
if stock_code in self.positions:
|
||||
# 기존 포지션에 추가 매수
|
||||
pos = self.positions[stock_code]
|
||||
new_quantity = pos.quantity + quantity
|
||||
new_avg_price = (pos.avg_price * pos.quantity + price * quantity) / new_quantity
|
||||
new_avg_price = (pos.avg_price * pos.quantity + exec_price * quantity) / new_quantity
|
||||
pos.quantity = new_quantity
|
||||
pos.avg_price = new_avg_price
|
||||
else:
|
||||
self.positions[stock_code] = Position(
|
||||
stock_code=stock_code,
|
||||
quantity=quantity,
|
||||
avg_price=price,
|
||||
avg_price=exec_price,
|
||||
entry_date=current_date,
|
||||
entry_reason=reason
|
||||
)
|
||||
|
||||
|
||||
self.trades.append(Trade(
|
||||
date=current_date,
|
||||
stock_code=stock_code,
|
||||
action="BUY",
|
||||
price=price,
|
||||
price=exec_price,
|
||||
quantity=quantity,
|
||||
amount=total_cost,
|
||||
commission=commission,
|
||||
@@ -219,28 +252,31 @@ class BacktestEngine:
|
||||
reason: str,
|
||||
config: BacktestConfig
|
||||
):
|
||||
"""매도 실행 (전량)"""
|
||||
"""매도 실행 (전량, 슬리피지 적용: 실제 체결가는 신호가보다 낮음)"""
|
||||
if stock_code not in self.positions:
|
||||
return
|
||||
|
||||
|
||||
# 슬리피지 적용: 매도 시 약간 낮은 가격에 체결
|
||||
exec_price = _apply_sell_slippage(price)
|
||||
|
||||
pos = self.positions[stock_code]
|
||||
total_amount = price * pos.quantity
|
||||
total_amount = exec_price * pos.quantity
|
||||
commission = self._calculate_commission(total_amount, True, config)
|
||||
net_amount = total_amount - commission
|
||||
|
||||
|
||||
self.cash += net_amount
|
||||
|
||||
|
||||
self.trades.append(Trade(
|
||||
date=current_date,
|
||||
stock_code=stock_code,
|
||||
action="SELL",
|
||||
price=price,
|
||||
price=exec_price,
|
||||
quantity=pos.quantity,
|
||||
amount=total_amount,
|
||||
commission=commission,
|
||||
reason=reason
|
||||
))
|
||||
|
||||
|
||||
del self.positions[stock_code]
|
||||
|
||||
def _generate_signals(
|
||||
@@ -254,15 +290,15 @@ class BacktestEngine:
|
||||
"""전략 신호 생성 (RSI + 볼린저밴드 평균회귀)"""
|
||||
if current_idx < 60: # 충분한 데이터 필요
|
||||
return None
|
||||
|
||||
|
||||
# 현재 시점의 피처
|
||||
rsi = features_df.loc[current_idx, "rsi_14"]
|
||||
bb_pos = features_df.loc[current_idx, "bb_position"]
|
||||
ma20_ratio = features_df.loc[current_idx, "ma20_ratio"]
|
||||
|
||||
|
||||
if pd.isna(rsi) or pd.isna(bb_pos) or pd.isna(ma20_ratio):
|
||||
return None
|
||||
|
||||
|
||||
# 전략 파라미터 (프리셋 또는 기본값)
|
||||
params = config.strategy_params or {}
|
||||
rsi_threshold = params.get("MEAN_REVERSION_RSI_THRESHOLD", 30)
|
||||
@@ -302,9 +338,9 @@ class BacktestEngine:
|
||||
return {
|
||||
"action": "SELL",
|
||||
"price": current_price,
|
||||
"reason": f"과매수 청산"
|
||||
"reason": "과매수 청산"
|
||||
}
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def _calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float:
|
||||
@@ -339,27 +375,32 @@ class BacktestEngine:
|
||||
trades=[],
|
||||
final_positions=[]
|
||||
)
|
||||
|
||||
# 거래별 수익률 계산
|
||||
buy_trades = {t.stock_code: t for t in self.trades if t.action == "BUY"}
|
||||
|
||||
# 거래별 수익률 계산 (FIFO 매칭: 동일 종목 복수 매수-매도 사이클 대응)
|
||||
buy_queues: Dict[str, deque] = defaultdict(deque)
|
||||
for t in self.trades:
|
||||
if t.action == "BUY":
|
||||
buy_queues[t.stock_code].append(t)
|
||||
|
||||
sell_trades = [t for t in self.trades if t.action == "SELL"]
|
||||
|
||||
|
||||
trade_returns = []
|
||||
for sell in sell_trades:
|
||||
if sell.stock_code in buy_trades:
|
||||
buy = buy_trades[sell.stock_code]
|
||||
if buy_queues[sell.stock_code]:
|
||||
buy = buy_queues[sell.stock_code].popleft()
|
||||
pnl = (sell.price - buy.price) * sell.quantity - sell.commission - buy.commission
|
||||
pnl_pct = pnl / (buy.price * buy.quantity)
|
||||
buy_cost = buy.price * buy.quantity
|
||||
pnl_pct = pnl / buy_cost if buy_cost > 0 else 0.0
|
||||
trade_returns.append(pnl_pct)
|
||||
|
||||
|
||||
win_trades = [r for r in trade_returns if r > 0]
|
||||
lose_trades = [r for r in trade_returns if r < 0]
|
||||
|
||||
|
||||
# 최종 자산
|
||||
final_value = self.equity_curve[-1]["value"] if self.equity_curve else config.initial_capital
|
||||
total_return = final_value - config.initial_capital
|
||||
total_return_pct = total_return / config.initial_capital
|
||||
|
||||
|
||||
# MDD 계산
|
||||
peak = config.initial_capital
|
||||
mdd = 0.0
|
||||
@@ -370,24 +411,28 @@ class BacktestEngine:
|
||||
if drawdown < mdd:
|
||||
mdd = drawdown
|
||||
mdd_pct = mdd / peak if peak > 0 else 0.0
|
||||
|
||||
# Sharpe/Sortino
|
||||
|
||||
# Sharpe/Sortino (무위험 이자율 3% 차감 -- kr_backtest_engine.py와 일관성 유지)
|
||||
if len(self.daily_returns) > 1:
|
||||
avg_return = np.mean(self.daily_returns)
|
||||
std_return = np.std(self.daily_returns, ddof=1)
|
||||
downside_returns = [r for r in self.daily_returns if r < 0]
|
||||
downside_std = np.std(downside_returns, ddof=1) if downside_returns else 1e-10
|
||||
|
||||
sharpe = (avg_return * 252) / (std_return * np.sqrt(252)) if std_return > 0 else 0.0
|
||||
sortino = (avg_return * 252) / (downside_std * np.sqrt(252)) if downside_std > 0 else 0.0
|
||||
rf_daily = 0.03 / 252
|
||||
excess_returns = [r - rf_daily for r in self.daily_returns]
|
||||
avg_excess = np.mean(excess_returns)
|
||||
std_excess = np.std(excess_returns, ddof=1)
|
||||
|
||||
downside_excess = [min(r, 0) for r in excess_returns]
|
||||
downside_var = np.mean([d ** 2 for d in downside_excess])
|
||||
downside_std = np.sqrt(downside_var) if downside_var > 0 else 1e-10
|
||||
|
||||
sharpe = (avg_excess / std_excess) * np.sqrt(252) if std_excess > 0 else 0.0
|
||||
sortino = (avg_excess / downside_std) * np.sqrt(252) if downside_std > 0 else 0.0
|
||||
else:
|
||||
sharpe = sortino = 0.0
|
||||
|
||||
|
||||
# Profit Factor
|
||||
total_win = sum(win_trades) if win_trades else 0.0
|
||||
total_lose = abs(sum(lose_trades)) if lose_trades else 1e-10
|
||||
profit_factor = total_win / total_lose if total_lose > 0 else 0.0
|
||||
|
||||
|
||||
return BacktestResult(
|
||||
config=asdict(config),
|
||||
total_trades=len(sell_trades),
|
||||
@@ -411,81 +456,114 @@ class BacktestEngine:
|
||||
)
|
||||
|
||||
async def run_backtest(self, config: BacktestConfig) -> BacktestResult:
|
||||
"""전략 성과 백테스트 실행"""
|
||||
"""전략 성과 백테스트 실행
|
||||
|
||||
[Look-ahead bias 수정]
|
||||
T일 close로 신호 생성 -> T+1일 open으로 체결.
|
||||
마지막 봉 신호는 다음 봉이 없으므로 무시합니다.
|
||||
"""
|
||||
logger.info(f"백테스트 시작: {config.start_date} ~ {config.end_date}, {len(config.stock_codes)}종목")
|
||||
|
||||
|
||||
self.cash = config.initial_capital
|
||||
self.positions = {}
|
||||
self.trades = []
|
||||
self.equity_curve = []
|
||||
self.daily_returns = []
|
||||
|
||||
|
||||
# 데이터 로드
|
||||
stock_data = await self._fetch_daily_data(
|
||||
config.stock_codes,
|
||||
config.start_date,
|
||||
config.end_date
|
||||
)
|
||||
|
||||
|
||||
if not stock_data:
|
||||
logger.warning("데이터 없음")
|
||||
return self._calculate_metrics(config)
|
||||
|
||||
|
||||
# 피처 추출
|
||||
features_data = {}
|
||||
for code, df in stock_data.items():
|
||||
features_df = extract_features(df)
|
||||
features_data[code] = features_df
|
||||
|
||||
|
||||
# 날짜별 시뮬레이션
|
||||
all_dates = sorted(set(
|
||||
date_val for df in stock_data.values()
|
||||
for date_val in df["date"].values
|
||||
))
|
||||
|
||||
|
||||
prev_portfolio_value = config.initial_capital
|
||||
|
||||
for current_date in all_dates:
|
||||
|
||||
# 전일 발생한 신호를 저장 (T일 close 신호 -> T+1일 open 체결)
|
||||
pending_signals: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for date_idx, current_date in enumerate(all_dates):
|
||||
fmt = "%Y%m%d" if len(current_date) == 8 else "%Y-%m-%d"
|
||||
current_date_obj = datetime.strptime(current_date, fmt).date()
|
||||
|
||||
# 각 종목별 신호 생성 및 주문 실행
|
||||
|
||||
# 종목별 현재 봉 정보 수집 (close, open, idx)
|
||||
current_prices = {}
|
||||
current_bar_info: Dict[str, Dict[str, Any]] = {}
|
||||
for code, df in stock_data.items():
|
||||
date_mask = df["date"] == current_date
|
||||
if not date_mask.any():
|
||||
continue
|
||||
|
||||
|
||||
current_idx = df.index[date_mask][0]
|
||||
current_price = df.loc[current_idx, "close"]
|
||||
current_prices[code] = current_price
|
||||
|
||||
# 신호 생성
|
||||
signal = self._generate_signals(
|
||||
code, df, features_data[code], current_idx, config
|
||||
)
|
||||
|
||||
if signal:
|
||||
if signal["action"] == "BUY":
|
||||
# 포지션 크기 제한
|
||||
if len(self.positions) < config.max_positions:
|
||||
position_amount = self.cash * config.position_size
|
||||
self._execute_buy(
|
||||
code, signal["price"], position_amount,
|
||||
current_date_obj, signal["reason"], config
|
||||
)
|
||||
elif signal["action"] == "SELL":
|
||||
self._execute_sell(
|
||||
code, signal["price"], current_date_obj,
|
||||
signal["reason"], config
|
||||
current_bar_info[code] = {
|
||||
"idx": current_idx,
|
||||
"open": df.loc[current_idx, "open"],
|
||||
"close": current_price,
|
||||
}
|
||||
|
||||
# -- 1단계: 전일 신호를 오늘 open으로 체결 --
|
||||
for code, signal in list(pending_signals.items()):
|
||||
bar_info = current_bar_info.get(code)
|
||||
if bar_info is None:
|
||||
# 오늘 해당 종목 데이터 없음 -> 신호 폐기
|
||||
continue
|
||||
|
||||
exec_price = bar_info["open"]
|
||||
|
||||
if signal["action"] == "BUY":
|
||||
if len(self.positions) < config.max_positions and code not in self.positions:
|
||||
position_amount = self.cash * config.position_size
|
||||
self._execute_buy(
|
||||
code, exec_price, position_amount,
|
||||
current_date_obj, signal["reason"], config
|
||||
)
|
||||
|
||||
elif signal["action"] == "SELL":
|
||||
self._execute_sell(
|
||||
code, exec_price, current_date_obj,
|
||||
signal["reason"], config
|
||||
)
|
||||
pending_signals.clear()
|
||||
|
||||
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
|
||||
is_last_bar = (date_idx == len(all_dates) - 1)
|
||||
if not is_last_bar:
|
||||
for code, df in stock_data.items():
|
||||
if code not in current_bar_info:
|
||||
continue
|
||||
|
||||
current_idx = current_bar_info[code]["idx"]
|
||||
|
||||
signal = self._generate_signals(
|
||||
code, df, features_data[code], current_idx, config
|
||||
)
|
||||
|
||||
if signal:
|
||||
pending_signals[code] = signal
|
||||
|
||||
# 일별 자산 기록
|
||||
portfolio_value = self._calculate_portfolio_value(current_prices)
|
||||
daily_return = (portfolio_value - prev_portfolio_value) / prev_portfolio_value
|
||||
daily_return = (portfolio_value - prev_portfolio_value) / prev_portfolio_value if prev_portfolio_value > 0 else 0.0
|
||||
self.daily_returns.append(daily_return)
|
||||
prev_portfolio_value = portfolio_value
|
||||
|
||||
|
||||
self.equity_curve.append({
|
||||
"date": current_date,
|
||||
"value": portfolio_value,
|
||||
@@ -493,7 +571,7 @@ class BacktestEngine:
|
||||
"positions": len(self.positions),
|
||||
"return_pct": (portfolio_value - config.initial_capital) / config.initial_capital
|
||||
})
|
||||
|
||||
|
||||
result = self._calculate_metrics(config)
|
||||
logger.info(f"백테스트 완료: 수익률 {result.total_return_pct*100:.2f}%, 승률 {result.win_rate*100:.1f}%")
|
||||
return result
|
||||
@@ -503,7 +581,7 @@ class BacktestEngine:
|
||||
|
||||
async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
|
||||
"""ML 학습 데이터 생성 (라벨링된 피처)
|
||||
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_samples": int,
|
||||
@@ -512,23 +590,23 @@ async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
|
||||
}
|
||||
"""
|
||||
logger.info(f"ML 학습 데이터 생성 시작: {len(config.stock_codes)}종목")
|
||||
|
||||
|
||||
engine = BacktestEngine()
|
||||
stock_data = await engine._fetch_daily_data(
|
||||
config.stock_codes,
|
||||
config.start_date,
|
||||
config.end_date
|
||||
)
|
||||
|
||||
|
||||
all_samples = []
|
||||
|
||||
|
||||
for stock_code, df in stock_data.items():
|
||||
if len(df) < config.forward_days + 60:
|
||||
continue
|
||||
|
||||
|
||||
# 피처 추출
|
||||
features_df = extract_features(df)
|
||||
|
||||
|
||||
# 라벨 생성
|
||||
X_tabular, X_seq, y, valid_indices = prepare_training_data(
|
||||
df, features_df,
|
||||
@@ -536,11 +614,11 @@ async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
|
||||
up_threshold=config.up_threshold,
|
||||
down_threshold=config.down_threshold
|
||||
)
|
||||
|
||||
|
||||
if len(valid_indices) < config.min_samples_per_stock:
|
||||
logger.warning(f"{stock_code}: 샘플 부족 ({len(valid_indices)}개)")
|
||||
continue
|
||||
|
||||
|
||||
# DataFrame으로 변환
|
||||
for i, idx in enumerate(valid_indices):
|
||||
sample = {
|
||||
@@ -550,21 +628,21 @@ async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
|
||||
"label": int(y[i])
|
||||
}
|
||||
all_samples.append(sample)
|
||||
|
||||
|
||||
result_df = pd.DataFrame(all_samples)
|
||||
|
||||
|
||||
if len(result_df) == 0:
|
||||
return {
|
||||
"error": "학습 데이터 생성 실패",
|
||||
"total_samples": 0,
|
||||
"label_distribution": {}
|
||||
}
|
||||
|
||||
|
||||
label_dist = result_df["label"].value_counts().to_dict()
|
||||
label_names = {0: "DOWN", 1: "HOLD", 2: "UP"}
|
||||
|
||||
|
||||
logger.info(f"ML 학습 데이터 생성 완료: {len(result_df)}샘플, 분포 {label_dist}")
|
||||
|
||||
|
||||
return {
|
||||
"total_samples": len(result_df),
|
||||
"label_distribution": {label_names[k]: int(v) for k, v in label_dist.items()},
|
||||
|
||||
@@ -455,9 +455,17 @@ async def db_writer_worker() -> None:
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if record is None:
|
||||
record = ActiveTrade(**data)
|
||||
# remaining_pct_multiply는 신규 생성 시 무시
|
||||
create_data = {k: v for k, v in data.items() if k != "remaining_pct_multiply"}
|
||||
record = ActiveTrade(**create_data)
|
||||
session.add(record)
|
||||
else:
|
||||
# remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함
|
||||
# (MDD 축소 시 기존 분할매도 이력 반영)
|
||||
multiply_ratio = data.pop("remaining_pct_multiply", None)
|
||||
if multiply_ratio is not None:
|
||||
current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0
|
||||
record.remaining_pct = current_remaining * float(multiply_ratio)
|
||||
for key, val in data.items():
|
||||
if key != "stock_code" and val is not None:
|
||||
setattr(record, key, val)
|
||||
|
||||
@@ -232,6 +232,7 @@ class KoreaInvestmentAPI:
|
||||
|
||||
# httpx 싱글톤 클라이언트 (커넥션 풀 재활용 — 매 요청마다 TCP/TLS 핸드셰이크 방지)
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
self._http_client_lock = asyncio.Lock()
|
||||
|
||||
# 토큰 발급 Lock (중복 발급 방지)
|
||||
self._token_lock = asyncio.Lock()
|
||||
@@ -250,7 +251,28 @@ class KoreaInvestmentAPI:
|
||||
|
||||
# 캐시된 토큰 로드
|
||||
self._load_token_from_cache()
|
||||
|
||||
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""싱글톤 httpx 클라이언트 반환. 없으면 생성."""
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
async with self._http_client_lock:
|
||||
if self._http_client is None or self._http_client.is_closed:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(10.0, connect=5.0),
|
||||
limits=httpx.Limits(
|
||||
max_connections=20,
|
||||
max_keepalive_connections=10,
|
||||
keepalive_expiry=30,
|
||||
),
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""httpx 클라이언트 정리. 앱 종료 시 호출."""
|
||||
if self._http_client and not self._http_client.is_closed:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
def _load_token_from_cache(self):
|
||||
"""파일에서 토큰 로드 (앱 시작 시 동기 호출)"""
|
||||
try:
|
||||
@@ -447,13 +469,13 @@ class KoreaInvestmentAPI:
|
||||
# 타임아웃 설정
|
||||
timeout = httpx.Timeout(self.timeout, connect=10.0)
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=headers,
|
||||
**kwargs
|
||||
)
|
||||
client = await self._get_http_client()
|
||||
response = await client.request(
|
||||
method,
|
||||
f"{self.base_url}{endpoint}",
|
||||
headers=headers,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# 성공
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -766,7 +766,12 @@ class KrBacktestEngine:
|
||||
market: str,
|
||||
random_seed: Optional[int],
|
||||
) -> BacktestResult:
|
||||
"""핵심 시뮬레이션 루프 (동기)"""
|
||||
"""핵심 시뮬레이션 루프 (동기)
|
||||
|
||||
[Look-ahead bias 수정]
|
||||
T일 close로 신호(on_bar) 생성 -> T+1일 open + 슬리피지로 체결.
|
||||
마지막 봉 신호는 다음 봉이 없으므로 무시합니다.
|
||||
"""
|
||||
cash = initial_capital
|
||||
positions: Dict[str, Position] = {}
|
||||
all_trades: List[BacktestTrade] = []
|
||||
@@ -784,6 +789,9 @@ class KrBacktestEngine:
|
||||
peak_equity = initial_capital
|
||||
seed_counter = random_seed if random_seed is not None else None
|
||||
|
||||
# 전일 신호를 저장 (T일 close 신호 -> T+1일 open 체결)
|
||||
pending_orders: List[Dict[str, Any]] = []
|
||||
|
||||
for bar_idx in range(len(all_dates)):
|
||||
current_date = all_dates[bar_idx]
|
||||
|
||||
@@ -795,23 +803,18 @@ class KrBacktestEngine:
|
||||
position_value += stock_bars[bar_idx].close_price * pos.quantity
|
||||
total_equity = cash + position_value
|
||||
|
||||
# 전략 호출
|
||||
orders = strategy.on_bar(
|
||||
bar_idx=bar_idx,
|
||||
bars=bars,
|
||||
positions=positions,
|
||||
cash=cash,
|
||||
total_equity=total_equity,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# 주문 실행
|
||||
for order in orders:
|
||||
# -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 --
|
||||
for order in pending_orders:
|
||||
action = order["action"]
|
||||
code = order["stock_code"]
|
||||
price = order["price"]
|
||||
quantity = order["quantity"]
|
||||
|
||||
# T+1일 open 가격을 base price로 사용
|
||||
stock_bars = bars.get(code, [])
|
||||
if bar_idx >= len(stock_bars):
|
||||
continue
|
||||
next_open = float(stock_bars[bar_idx].open_price)
|
||||
|
||||
# 슬리피지용 시드
|
||||
if seed_counter is not None:
|
||||
seed_counter += 1
|
||||
@@ -820,7 +823,7 @@ class KrBacktestEngine:
|
||||
slip_seed = None
|
||||
|
||||
if action == "BUY":
|
||||
exec_price = _apply_buy_slippage(price, slip_seed)
|
||||
exec_price = _apply_buy_slippage(next_open, slip_seed)
|
||||
total_cost, commission = _calc_buy_cost(exec_price, quantity)
|
||||
|
||||
if total_cost > cash:
|
||||
@@ -831,7 +834,7 @@ class KrBacktestEngine:
|
||||
total_cost, commission = _calc_buy_cost(exec_price, quantity)
|
||||
|
||||
cash -= total_cost
|
||||
slippage_amount = abs(exec_price - price) * quantity
|
||||
slippage_amount = abs(exec_price - next_open) * quantity
|
||||
|
||||
trade = BacktestTrade(
|
||||
stock_code=code,
|
||||
@@ -859,14 +862,13 @@ class KrBacktestEngine:
|
||||
if pos is None:
|
||||
continue
|
||||
|
||||
sell_pct = order.get("sell_pct", 1.0)
|
||||
sell_qty = min(quantity, pos.quantity)
|
||||
|
||||
exec_price = _apply_sell_slippage(price, slip_seed)
|
||||
exec_price = _apply_sell_slippage(next_open, slip_seed)
|
||||
proceeds, commission, tax = _calc_sell_proceeds(exec_price, sell_qty, market)
|
||||
|
||||
cash += proceeds
|
||||
slippage_amount = abs(price - exec_price) * sell_qty
|
||||
slippage_amount = abs(next_open - exec_price) * sell_qty
|
||||
|
||||
# PnL 계산
|
||||
entry_cost = pos.entry_price * sell_qty * (1 + BUY_COMMISSION_RATE)
|
||||
@@ -899,6 +901,29 @@ class KrBacktestEngine:
|
||||
pos.quantity = remaining_qty
|
||||
pos.remaining_pct = remaining_qty / (pos.quantity + sell_qty)
|
||||
|
||||
pending_orders.clear()
|
||||
|
||||
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
|
||||
is_last_bar = (bar_idx == len(all_dates) - 1)
|
||||
if not is_last_bar:
|
||||
# 체결 이후의 상태로 평가 자산 재계산 (전략에 전달)
|
||||
position_value = 0.0
|
||||
for code, pos in positions.items():
|
||||
stock_bars_eval = bars.get(code, [])
|
||||
if bar_idx < len(stock_bars_eval):
|
||||
position_value += stock_bars_eval[bar_idx].close_price * pos.quantity
|
||||
total_equity = cash + position_value
|
||||
|
||||
orders = strategy.on_bar(
|
||||
bar_idx=bar_idx,
|
||||
bars=bars,
|
||||
positions=positions,
|
||||
cash=cash,
|
||||
total_equity=total_equity,
|
||||
params=params,
|
||||
)
|
||||
pending_orders = list(orders)
|
||||
|
||||
# 일말 평가
|
||||
position_value = 0.0
|
||||
for code, pos in positions.items():
|
||||
|
||||
@@ -277,11 +277,15 @@ class MDDManager:
|
||||
await enqueue_close_active_trade(stock_code)
|
||||
else:
|
||||
# 부분 매도: remaining_pct 갱신
|
||||
# 기존 ActiveTrade의 remaining_pct에 매도 비율을 곱해야 함
|
||||
# (예: 기존 0.5 * 남은비율 0.7 = 0.35)
|
||||
# db_worker가 직접 setattr하므로, reduce_ratio를 전달하여
|
||||
# 워커가 기존 값에 곱하도록 처리
|
||||
from app.services.db_worker import enqueue_active_trade_upsert
|
||||
new_remaining = remaining_qty / quantity
|
||||
sell_ratio = remaining_qty / quantity # 남은 비율 (0.7 or 0.5)
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": stock_code,
|
||||
"remaining_pct": new_remaining,
|
||||
"remaining_pct_multiply": sell_ratio,
|
||||
})
|
||||
|
||||
results.append({
|
||||
|
||||
@@ -47,6 +47,11 @@ class OptimizationResult:
|
||||
applied: bool
|
||||
apply_reason: str
|
||||
total_combinations: int
|
||||
# IS/OOS 검증 결과 (v2 추가)
|
||||
oos_sharpe: float = 0.0
|
||||
oos_win_rate: float = 0.0
|
||||
oos_mdd: float = 0.0
|
||||
is_oos_sharpe_ratio: float = 0.0 # OOS Sharpe / IS Sharpe (1.0에 가까울수록 건전)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -282,7 +287,7 @@ async def run_optimization(
|
||||
Returns:
|
||||
OptimizationResult
|
||||
"""
|
||||
logger.info(f"🔧 전략 최적화 시작 (trigger={trigger}, period={period_days}일)")
|
||||
logger.info(f"전략 최적화 시작 (trigger={trigger}, period={period_days}일)")
|
||||
|
||||
# 1. 현재 파라미터 저장 (롤백용)
|
||||
prev_params = await _get_current_params()
|
||||
@@ -310,16 +315,26 @@ async def run_optimization(
|
||||
total_combinations=0,
|
||||
)
|
||||
|
||||
# ── IS/OOS 기간 분할 (70% IS / 30% OOS, 날짜 기준) ──
|
||||
end_date = date.today()
|
||||
start_date = end_date - timedelta(days=period_days)
|
||||
is_days = int(period_days * 0.7)
|
||||
is_end_date = start_date + timedelta(days=is_days)
|
||||
oos_start_date = is_end_date + timedelta(days=1)
|
||||
oos_end_date = end_date
|
||||
|
||||
# 4. 현재 파라미터 기준 Sharpe 계산
|
||||
cur_score, cur_sharpe, cur_wr, cur_mdd = await _evaluate_params(
|
||||
prev_params, stock_codes, start_date, end_date, initial_capital
|
||||
logger.info(
|
||||
f" IS 기간: {start_date} ~ {is_end_date} ({is_days}일), "
|
||||
f"OOS 기간: {oos_start_date} ~ {oos_end_date} ({period_days - is_days}일)"
|
||||
)
|
||||
logger.info(f" 현재 성과 → Sharpe: {cur_sharpe:.3f}, 승률: {cur_wr:.1f}%, MDD: {cur_mdd:.1f}%")
|
||||
|
||||
# 5. 그리드 서치 (배치 5개씩 병렬)
|
||||
# 4. 현재 파라미터 기준 Sharpe 계산 (IS 기간)
|
||||
cur_score, cur_sharpe, cur_wr, cur_mdd = await _evaluate_params(
|
||||
prev_params, stock_codes, start_date, is_end_date, initial_capital
|
||||
)
|
||||
logger.info(f" 현재 IS 성과 -> Sharpe: {cur_sharpe:.3f}, 승률: {cur_wr:.1f}%, MDD: {cur_mdd:.1f}%")
|
||||
|
||||
# 5. 그리드 서치 — IS 기간에서 최적 파라미터 탐색 (배치 5개씩 병렬)
|
||||
best_score = cur_score
|
||||
best_params = dict(prev_params)
|
||||
best_sharpe = cur_sharpe
|
||||
@@ -330,7 +345,7 @@ async def run_optimization(
|
||||
for i in range(0, len(combinations), batch_size):
|
||||
batch = combinations[i:i + batch_size]
|
||||
tasks = [
|
||||
_evaluate_params(p, stock_codes, start_date, end_date, initial_capital)
|
||||
_evaluate_params(p, stock_codes, start_date, is_end_date, initial_capital)
|
||||
for p in batch
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -345,39 +360,65 @@ async def run_optimization(
|
||||
best_mdd = mdd
|
||||
best_params = {**prev_params, **combo}
|
||||
|
||||
logger.info(f" 최적 성과 → Sharpe: {best_sharpe:.3f}, 승률: {best_win_rate:.1f}%, MDD: {best_mdd:.1f}%")
|
||||
logger.info(f" IS 최적 성과 -> Sharpe: {best_sharpe:.3f}, 승률: {best_win_rate:.1f}%, MDD: {best_mdd:.1f}%")
|
||||
|
||||
# 6. 자동 적용 여부 결정 (Sharpe 10% 이상 개선)
|
||||
# 6. OOS 기간에서 최적 파라미터 검증 (과적합 탐지)
|
||||
oos_score, oos_sharpe, oos_wr, oos_mdd = await _evaluate_params(
|
||||
best_params, stock_codes, oos_start_date, oos_end_date, initial_capital
|
||||
)
|
||||
is_oos_ratio = (oos_sharpe / best_sharpe) if best_sharpe > 0 else 0.0
|
||||
logger.info(
|
||||
f" OOS 검증 -> Sharpe: {oos_sharpe:.3f}, 승률: {oos_wr:.1f}%, MDD: {oos_mdd:.1f}%, "
|
||||
f"IS/OOS Sharpe 비율: {is_oos_ratio:.2f}"
|
||||
)
|
||||
|
||||
if is_oos_ratio < 0.5 and best_sharpe > 0:
|
||||
logger.warning(
|
||||
f" [과적합 경고] OOS Sharpe / IS Sharpe = {is_oos_ratio:.2f} < 0.5 "
|
||||
"-- 파라미터가 IS 기간에 과적합되었을 가능성 높음"
|
||||
)
|
||||
|
||||
# 7. 자동 적용 여부 결정
|
||||
# 조건: IS Sharpe 10% 이상 개선 AND OOS Sharpe > 0 (OOS에서도 양의 성과)
|
||||
improvement_threshold = 1.1
|
||||
should_apply = (
|
||||
_is_improved = (
|
||||
cur_sharpe > 0
|
||||
and best_sharpe >= cur_sharpe * improvement_threshold
|
||||
and best_params != prev_params
|
||||
)
|
||||
if cur_sharpe <= 0 and best_sharpe > 0:
|
||||
should_apply = True
|
||||
_is_improved = True
|
||||
|
||||
# OOS 검증 게이트: OOS Sharpe > 0 이어야 실전 적용 허가
|
||||
_oos_passed = oos_sharpe > 0
|
||||
_should_apply = _is_improved and _oos_passed
|
||||
|
||||
applied = False
|
||||
apply_reason = ""
|
||||
|
||||
if should_apply:
|
||||
if _should_apply:
|
||||
improvement_pct = ((best_sharpe - cur_sharpe) / max(abs(cur_sharpe), 0.001)) * 100
|
||||
apply_reason = f"Sharpe {improvement_pct:.1f}% 향상 ({cur_sharpe:.3f} → {best_sharpe:.3f})"
|
||||
apply_reason = (
|
||||
f"IS Sharpe {improvement_pct:.1f}% 향상 ({cur_sharpe:.3f} -> {best_sharpe:.3f}), "
|
||||
f"OOS Sharpe: {oos_sharpe:.3f}, IS/OOS 비율: {is_oos_ratio:.2f}"
|
||||
)
|
||||
# 최적화된 파라미터만 적용 (prev와 다른 키만)
|
||||
changed = {k: v for k, v in best_params.items() if str(v) != str(prev_params.get(k, ""))}
|
||||
if changed:
|
||||
applied = await apply_params(changed, reason="auto_optimizer")
|
||||
if applied:
|
||||
logger.info(f"✅ 자동 적용 완료: {apply_reason}")
|
||||
logger.info(f"자동 적용 완료: {apply_reason}")
|
||||
else:
|
||||
if cur_sharpe <= 0 and best_sharpe <= 0:
|
||||
apply_reason = "insufficient_data"
|
||||
elif not _oos_passed and _is_improved:
|
||||
apply_reason = f"OOS 검증 실패 (OOS Sharpe: {oos_sharpe:.3f} <= 0, 과적합 의심)"
|
||||
elif best_sharpe < cur_sharpe * improvement_threshold:
|
||||
pct = ((best_sharpe - cur_sharpe) / max(abs(cur_sharpe), 0.001)) * 100
|
||||
apply_reason = f"개선 미달 ({pct:+.1f}% < 10%)"
|
||||
apply_reason = f"IS 개선 미달 ({pct:+.1f}% < 10%)"
|
||||
else:
|
||||
apply_reason = "no_improvement"
|
||||
logger.info(f"⏭️ 자동 적용 건너뜀: {apply_reason}")
|
||||
logger.info(f"자동 적용 건너뜀: {apply_reason}")
|
||||
|
||||
return OptimizationResult(
|
||||
best_params=best_params,
|
||||
@@ -390,6 +431,10 @@ async def run_optimization(
|
||||
applied=applied,
|
||||
apply_reason=apply_reason,
|
||||
total_combinations=total_combos,
|
||||
oos_sharpe=oos_sharpe,
|
||||
oos_win_rate=oos_wr,
|
||||
oos_mdd=oos_mdd,
|
||||
is_oos_sharpe_ratio=round(is_oos_ratio, 4),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ strategy_type='swing' 진입만 처리하는 독립 서비스.
|
||||
- SIDEWAYS: 진입 없음 (CLAUDE.md 6.1)
|
||||
- 포지션 사이징: 종목당 총자본 6% (MAX_POSITION_SIZE), 최대 25종목
|
||||
|
||||
듀얼 전략 (CLAUDE.md 섹션 8.4):
|
||||
- 모멘텀 돌파: 20일 신고가 + 거래량 2x + RSI 50~70 (BULL만)
|
||||
- 눌림목 매수: MA20>MA60 + MA20 근처 조정 + RSI 반등 (BULL만)
|
||||
|
||||
진입 전 체크:
|
||||
1. 장 오픈 여부 (정규장 09:00~15:30만 허용)
|
||||
2. 스윙 포지션 한도 (25종목)
|
||||
@@ -20,10 +24,11 @@ strategy_type='swing' 진입만 처리하는 독립 서비스.
|
||||
import asyncio
|
||||
import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, desc
|
||||
from datetime import date
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.models import Portfolio
|
||||
from app.models.models import Portfolio, StockAnalysis
|
||||
from app.services.trade_executor import execute_auto_trade, execute_auto_trade_background
|
||||
from app.services.market_analyzer import check_market_regime
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
@@ -144,3 +149,180 @@ def _inject_strategy_type(rec: dict, strategy_type: str) -> None:
|
||||
for stock in group:
|
||||
if isinstance(stock, dict):
|
||||
stock["strategy_type"] = strategy_type
|
||||
|
||||
|
||||
async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
|
||||
"""듀얼 전략 시그널 스캔 및 진입 실행 (CLAUDE.md 섹션 8.4).
|
||||
|
||||
배치 분석으로 저장된 stock_analysis 종목들의 일봉 차트를 조회하여
|
||||
모멘텀 돌파 / 눌림목 매수 시그널을 탐색하고, 기존 매매 파이프라인으로 진입.
|
||||
|
||||
BULL 시장에서만 실행. SIDEWAYS/BEAR에서는 스킵.
|
||||
|
||||
Returns:
|
||||
list[dict]: 실행된 매수 결과 목록
|
||||
"""
|
||||
from app.core.market_schedule import is_market_open
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.mean_reversion_strategy import (
|
||||
check_momentum_breakout_signal,
|
||||
check_pullback_buy_signal,
|
||||
calculate_rsi,
|
||||
)
|
||||
|
||||
if not is_market_open():
|
||||
return []
|
||||
|
||||
# BULL 시장에서만 듀얼 전략 실행
|
||||
try:
|
||||
regime = await check_market_regime()
|
||||
regime_str = regime.get("regime_200d", "bull") if isinstance(regime, dict) else "bull"
|
||||
if regime_str != "bull":
|
||||
logger.debug("[DualStrategy] BULL이 아님 (%s) — 듀얼 전략 스킵", regime_str)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"[DualStrategy] 시장 국면 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
# 포지션 한도 체크
|
||||
current_count = await count_swing_positions(db)
|
||||
remaining_slots = MAX_SWING_POSITIONS - current_count
|
||||
if remaining_slots <= 0:
|
||||
return []
|
||||
|
||||
# MDD 체크
|
||||
if not await mdd_manager.can_open_new_position():
|
||||
return []
|
||||
|
||||
# Crisis 모드 체크
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used and _adp.trading_mode == "crisis":
|
||||
return []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 현재 보유 종목 (중복 진입 방지)
|
||||
result = await db.execute(select(Portfolio.stock_code).where(Portfolio.quantity > 0))
|
||||
held_codes = {row[0] for row in result.fetchall()}
|
||||
|
||||
# 최근 분석된 종목 중 상위 30개 조회 (score 기준)
|
||||
analysis_query = (
|
||||
select(StockAnalysis)
|
||||
.where(StockAnalysis.analysis_date >= date.today())
|
||||
.order_by(desc(StockAnalysis.score))
|
||||
.limit(30)
|
||||
)
|
||||
analysis_result = await db.execute(analysis_query)
|
||||
candidates = analysis_result.scalars().all()
|
||||
|
||||
if not candidates:
|
||||
logger.debug("[DualStrategy] 분석 데이터 없음 — 스킵")
|
||||
return []
|
||||
|
||||
dual_stocks = []
|
||||
scan_count = 0
|
||||
|
||||
for stock in candidates:
|
||||
if scan_count >= 10:
|
||||
break
|
||||
if stock.stock_code in held_codes:
|
||||
continue
|
||||
if float(stock.current_price or 0) < settings.MIN_PRICE_KRW:
|
||||
continue
|
||||
|
||||
try:
|
||||
chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60)
|
||||
output2 = chart.get("output2", [])
|
||||
if not output2 or len(output2) < 20:
|
||||
continue
|
||||
|
||||
# 최신이 앞에 오므로 reverse
|
||||
bars = list(reversed(output2))
|
||||
closes = [float(b.get("stck_clpr", 0)) for b in bars]
|
||||
highs = [float(b.get("stck_hgpr", 0)) for b in bars]
|
||||
volumes = [int(b.get("acml_vol", 0)) for b in bars]
|
||||
|
||||
if not closes or closes[-1] <= 0:
|
||||
continue
|
||||
|
||||
current_close = closes[-1]
|
||||
current_volume = volumes[-1] if volumes else 0
|
||||
|
||||
rsi_values = calculate_rsi(closes, period=14)
|
||||
rsi = rsi_values[-1] if rsi_values else 50.0
|
||||
rsi_prev = rsi_values[-2] if len(rsi_values) >= 2 else None
|
||||
|
||||
scan_count += 1
|
||||
|
||||
# 모멘텀 돌파 체크
|
||||
momentum_signal = check_momentum_breakout_signal(
|
||||
closes=closes, highs=highs, volumes=volumes,
|
||||
current_close=current_close, current_volume=current_volume, rsi=rsi,
|
||||
)
|
||||
if momentum_signal:
|
||||
dual_stocks.append({
|
||||
"stock_code": stock.stock_code,
|
||||
"stock_name": stock.stock_name,
|
||||
"current_price": int(current_close),
|
||||
"score": 0.8,
|
||||
"signal": momentum_signal["description"],
|
||||
"strategy_type": "swing",
|
||||
"entry_reason": f"[MOMENTUM_BREAKOUT] {momentum_signal['description']}",
|
||||
})
|
||||
logger.info(
|
||||
f"[DualStrategy] 모멘텀 돌파 시그널: {stock.stock_code} {stock.stock_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 눌림목 매수 체크
|
||||
pullback_signal = check_pullback_buy_signal(
|
||||
closes=closes, current_close=current_close,
|
||||
current_volume=current_volume, rsi=rsi, rsi_prev=rsi_prev,
|
||||
volumes=volumes,
|
||||
)
|
||||
if pullback_signal:
|
||||
dual_stocks.append({
|
||||
"stock_code": stock.stock_code,
|
||||
"stock_name": stock.stock_name,
|
||||
"current_price": int(current_close),
|
||||
"score": 0.6,
|
||||
"signal": pullback_signal["description"],
|
||||
"strategy_type": "swing",
|
||||
"entry_reason": f"[PULLBACK_BUY] {pullback_signal['description']}",
|
||||
})
|
||||
logger.info(
|
||||
f"[DualStrategy] 눌림목 매수 시그널: {stock.stock_code} {stock.stock_name}"
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.3) # KIS API 레이트 리밋
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[DualStrategy] {stock.stock_code} 스캔 실패: {e}")
|
||||
continue
|
||||
|
||||
if not dual_stocks:
|
||||
logger.debug("[DualStrategy] 시그널 없음")
|
||||
return []
|
||||
|
||||
# 남은 슬롯 이내로 제한
|
||||
dual_stocks = dual_stocks[:remaining_slots]
|
||||
|
||||
# execute_auto_trade_background 형식으로 변환하여 실행
|
||||
rec = {
|
||||
"recommendations_by_rule": [{
|
||||
"rule_name": "dual_strategy",
|
||||
"stocks": dual_stocks,
|
||||
}]
|
||||
}
|
||||
|
||||
try:
|
||||
result = await execute_auto_trade_background(rec, db)
|
||||
if isinstance(result, list):
|
||||
logger.info(f"[DualStrategy] {len(result)}건 진입 실행 완료")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[DualStrategy] 진입 실행 오류: {e}", exc_info=True)
|
||||
|
||||
return []
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.core.database import AsyncSessionLocal
|
||||
from app.core.market_schedule import is_market_open
|
||||
from app.services.mdd_manager import mdd_manager
|
||||
from app.services.swing.exit_service import check_and_execute_exit_v2
|
||||
from app.services.swing.entry_service import execute_swing_entries
|
||||
from app.services.swing.entry_service import execute_swing_entries, scan_dual_strategy_signals
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
@@ -57,6 +57,25 @@ def _normalize_recommendations(rec: dict) -> dict:
|
||||
return rec
|
||||
|
||||
|
||||
def _filter_unsuitable_stocks(rec: dict, unsuitable_codes: set) -> None:
|
||||
"""장 전 AI 분석에서 부적합 판정된 종목을 추천 목록에서 제거."""
|
||||
groups = rec.get("recommendations_by_rule", [])
|
||||
if isinstance(groups, list):
|
||||
for group in groups:
|
||||
if isinstance(group, dict) and "stocks" in group:
|
||||
group["stocks"] = [
|
||||
s for s in group["stocks"]
|
||||
if isinstance(s, dict) and s.get("stock_code") not in unsuitable_codes
|
||||
]
|
||||
elif isinstance(groups, dict):
|
||||
for key, stocks in groups.items():
|
||||
if isinstance(stocks, list):
|
||||
groups[key] = [
|
||||
s for s in stocks
|
||||
if isinstance(s, dict) and s.get("stock_code") not in unsuitable_codes
|
||||
]
|
||||
|
||||
|
||||
async def swing_scheduler() -> None:
|
||||
"""60초 주기 스윙 트레이딩 스케줄러.
|
||||
|
||||
@@ -149,7 +168,25 @@ async def swing_scheduler() -> None:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# 5. MCP RISK_ASSESSMENT
|
||||
# 5. 장 전 AI 분석 결과 활용 + MCP 리스크 평가 (최적화)
|
||||
# 장 전 Sonnet 배치 분석 결과가 Redis에 있으면 부적합 종목을 사전 필터링
|
||||
# MCP RISK_ASSESSMENT는 실제 매수 직전에만 호출 (비용 절감)
|
||||
_ai_filtered_codes = set()
|
||||
try:
|
||||
_ai_pre = await get_json("ai:pre_market:latest")
|
||||
if _ai_pre and isinstance(_ai_pre, dict):
|
||||
for s in _ai_pre.get("suitable_stocks", []):
|
||||
if isinstance(s, dict) and not s.get("suitable", True):
|
||||
_ai_filtered_codes.add(s.get("stock_code", ""))
|
||||
if _ai_filtered_codes:
|
||||
logger.info(f"[Swing] AI 장 전 분석 필터: {len(_ai_filtered_codes)}종목 제외")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 추천 목록에서 AI 부적합 종목 제거
|
||||
if _ai_filtered_codes:
|
||||
_filter_unsuitable_stocks(rec, _ai_filtered_codes)
|
||||
|
||||
recommendations = rec.get("recommendations_by_rule", [])
|
||||
if isinstance(recommendations, dict):
|
||||
rec_count = sum(len(v) for v in recommendations.values())
|
||||
@@ -169,6 +206,12 @@ async def swing_scheduler() -> None:
|
||||
for s in g.get("stocks", [])[:3]
|
||||
][:10]
|
||||
|
||||
if not top_stocks:
|
||||
logger.info("[Swing] 필터링 후 추천 종목 없음 — 스킵")
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
# MCP 리스크 평가 (Haiku — 실제 진입 직전 1회)
|
||||
trade_plan = {
|
||||
"action": "BUY",
|
||||
"strategy": "swing",
|
||||
@@ -196,7 +239,17 @@ async def swing_scheduler() -> None:
|
||||
async with AsyncSessionLocal() as entry_session:
|
||||
logger.info(f"[Swing] 진입 실행 시작: {top_stocks[:5]}")
|
||||
await execute_swing_entries(rec, entry_session)
|
||||
logger.info("[Swing] ── 사이클 완료 ──")
|
||||
|
||||
# 7. 듀얼 전략 시그널 스캔 (모멘텀 돌파 + 눌림목 매수, CLAUDE.md 8.4)
|
||||
try:
|
||||
async with AsyncSessionLocal() as dual_session:
|
||||
dual_results = await scan_dual_strategy_signals(dual_session)
|
||||
if dual_results:
|
||||
logger.info(f"[Swing] 듀얼 전략 진입: {len(dual_results)}건")
|
||||
except Exception as e:
|
||||
logger.debug(f"[Swing] 듀얼 전략 스캔 오류: {e}")
|
||||
|
||||
logger.info("[Swing] ── 사이클 완료 ──")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Swing] 스케줄러 사이클 오류: {e}", exc_info=True)
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
[역할]
|
||||
- TradeHistory 레코드를 분석해 전략 성과 통계를 생성
|
||||
- 전략별 / 요일별 승률, profit factor, 패턴 분석
|
||||
- 리스크 조정 수익률 지표 (Sharpe, Sortino, Calmar)
|
||||
- 전략 드리프트 감지 (롤링 승률, 롤링 Sharpe, 연속 손실, MDD 가속)
|
||||
- 완료 거래 대상 매매일지 템플릿 자동 생성
|
||||
- journals.py / analysis.py 엔드포인트에서 사용
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Any, Dict, List, Optional
|
||||
from decimal import Decimal
|
||||
@@ -70,6 +74,276 @@ def _parse_exit_type(exit_reason: Optional[str]) -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 리스크 조정 수익률 지표 헬퍼
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 무위험이자율 3% (연), 일별 = 0.03/252
|
||||
_RISK_FREE_ANNUAL = 0.03
|
||||
_RISK_FREE_DAILY = _RISK_FREE_ANNUAL / 252.0
|
||||
# 연환산 계수
|
||||
_ANNUALIZE_FACTOR = math.sqrt(252)
|
||||
# 최소 거래일 수 (통계적 유의성)
|
||||
_MIN_TRADING_DAYS = 20
|
||||
|
||||
|
||||
def _build_daily_pnl_series(trades: List["TradeHistory"]) -> Dict[date, float]:
|
||||
"""SELL 거래의 created_at 날짜별 net_profit_loss를 집계하여 일별 PnL 딕셔너리 반환.
|
||||
|
||||
Returns:
|
||||
{date(2026,3,1): 15000.0, date(2026,3,2): -3200.0, ...}
|
||||
"""
|
||||
daily_pnl: Dict[date, float] = defaultdict(float)
|
||||
for t in trades:
|
||||
if t.created_at is None:
|
||||
continue
|
||||
day = t.created_at.date() if isinstance(t.created_at, datetime) else t.created_at
|
||||
daily_pnl[day] += _to_float(t.net_profit_loss)
|
||||
return dict(daily_pnl)
|
||||
|
||||
|
||||
def _daily_pnl_to_returns(daily_pnl: Dict[date, float], initial_capital: float = 10_000_000.0) -> List[float]:
|
||||
"""일별 PnL을 누적 equity curve로 변환 후 일별 수익률(%) 시계열 반환.
|
||||
|
||||
Args:
|
||||
daily_pnl: 날짜별 PnL 금액
|
||||
initial_capital: 기준 자본금 (기본 1000만원, equity curve 계산용)
|
||||
|
||||
Returns:
|
||||
일별 수익률 리스트 (소수 형태, 예: 0.01 = 1%)
|
||||
"""
|
||||
if not daily_pnl:
|
||||
return []
|
||||
|
||||
sorted_days = sorted(daily_pnl.keys())
|
||||
equity = initial_capital
|
||||
daily_returns = []
|
||||
|
||||
for day in sorted_days:
|
||||
pnl = daily_pnl[day]
|
||||
if equity <= 0:
|
||||
# 파산 방지 — 잔여 자본이 0 이하이면 중단
|
||||
break
|
||||
ret = pnl / equity
|
||||
daily_returns.append(ret)
|
||||
equity += pnl
|
||||
|
||||
return daily_returns
|
||||
|
||||
|
||||
def _calc_sharpe_ratio(daily_returns: List[float]) -> Optional[float]:
|
||||
"""Sharpe Ratio = (R - Rf) / sigma(R) * sqrt(252)
|
||||
|
||||
일별 수익률 시계열로부터 연환산 Sharpe Ratio를 계산.
|
||||
거래일 _MIN_TRADING_DAYS 미만이면 None 반환.
|
||||
"""
|
||||
if len(daily_returns) < _MIN_TRADING_DAYS:
|
||||
return None
|
||||
|
||||
n = len(daily_returns)
|
||||
mean_r = sum(daily_returns) / n
|
||||
excess = mean_r - _RISK_FREE_DAILY
|
||||
|
||||
# 표본 표준편차
|
||||
variance = sum((r - mean_r) ** 2 for r in daily_returns) / (n - 1)
|
||||
std = math.sqrt(variance) if variance > 0 else 0.0
|
||||
|
||||
if std == 0:
|
||||
return None
|
||||
|
||||
return round((excess / std) * _ANNUALIZE_FACTOR, 4)
|
||||
|
||||
|
||||
def _calc_sortino_ratio(daily_returns: List[float]) -> Optional[float]:
|
||||
"""Sortino Ratio = (R - Rf) / sigma_down(R) * sqrt(252)
|
||||
|
||||
하방 편차(downside deviation)만 사용 — 수익률이 무위험이자율 미만인 경우만 패널티.
|
||||
"""
|
||||
if len(daily_returns) < _MIN_TRADING_DAYS:
|
||||
return None
|
||||
|
||||
n = len(daily_returns)
|
||||
mean_r = sum(daily_returns) / n
|
||||
excess = mean_r - _RISK_FREE_DAILY
|
||||
|
||||
# 하방 편차: Rf 미만인 수익률에 대해서만 편차 계산
|
||||
downside_diffs = [(r - _RISK_FREE_DAILY) ** 2 for r in daily_returns if r < _RISK_FREE_DAILY]
|
||||
if not downside_diffs:
|
||||
# 하방이 전혀 없으면 Sortino 무한대 — None 대신 높은 값 표시
|
||||
return 99.0 if excess > 0 else None
|
||||
|
||||
downside_variance = sum(downside_diffs) / n # 전체 N으로 나눔 (하방 편차 관행)
|
||||
downside_std = math.sqrt(downside_variance)
|
||||
|
||||
if downside_std == 0:
|
||||
return None
|
||||
|
||||
return round((excess / downside_std) * _ANNUALIZE_FACTOR, 4)
|
||||
|
||||
|
||||
def _calc_calmar_ratio(daily_returns: List[float]) -> Optional[float]:
|
||||
"""Calmar Ratio = CAGR / |MDD|
|
||||
|
||||
일별 수익률로부터 CAGR과 MDD를 계산.
|
||||
"""
|
||||
if len(daily_returns) < _MIN_TRADING_DAYS:
|
||||
return None
|
||||
|
||||
# Equity curve 구성 (1.0 기준)
|
||||
equity = 1.0
|
||||
equities = [equity]
|
||||
for r in daily_returns:
|
||||
equity *= (1.0 + r)
|
||||
equities.append(equity)
|
||||
|
||||
final_equity = equities[-1]
|
||||
n_days = len(daily_returns)
|
||||
|
||||
# CAGR (연환산 복합 성장률)
|
||||
if final_equity <= 0 or n_days == 0:
|
||||
return None
|
||||
cagr = (final_equity ** (252.0 / n_days)) - 1.0
|
||||
|
||||
# MDD 계산
|
||||
peak = equities[0]
|
||||
max_dd = 0.0
|
||||
for eq in equities:
|
||||
if eq > peak:
|
||||
peak = eq
|
||||
dd = (eq - peak) / peak if peak > 0 else 0.0
|
||||
if dd < max_dd:
|
||||
max_dd = dd
|
||||
|
||||
if max_dd == 0:
|
||||
# drawdown이 없으면 — 전체 수익만 발생
|
||||
return 99.0 if cagr > 0 else None
|
||||
|
||||
return round(cagr / abs(max_dd), 4)
|
||||
|
||||
|
||||
def _calc_risk_adjusted_metrics(trades: List["TradeHistory"]) -> Dict[str, Any]:
|
||||
"""Sharpe, Sortino, Calmar Ratio를 한 번에 계산하여 딕셔너리 반환.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"sharpe_ratio": float | None,
|
||||
"sortino_ratio": float | None,
|
||||
"calmar_ratio": float | None,
|
||||
"daily_returns_count": int
|
||||
}
|
||||
"""
|
||||
daily_pnl = _build_daily_pnl_series(trades)
|
||||
daily_returns = _daily_pnl_to_returns(daily_pnl)
|
||||
|
||||
return {
|
||||
"sharpe_ratio": _calc_sharpe_ratio(daily_returns),
|
||||
"sortino_ratio": _calc_sortino_ratio(daily_returns),
|
||||
"calmar_ratio": _calc_calmar_ratio(daily_returns),
|
||||
"daily_returns_count": len(daily_returns),
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 전략 드리프트 감지 헬퍼
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_DRIFT_ROLLING_WINDOW = 20 # 최근 20거래 / 20거래일
|
||||
_DRIFT_WIN_RATE_THRESHOLD = 0.30 # 30% 미만 경고
|
||||
_DRIFT_SHARPE_THRESHOLD = 0.0 # 0 이하 경고
|
||||
_DRIFT_LOSS_STREAK_THRESHOLD = 5 # 5연패 이상 경고
|
||||
_DRIFT_DRAWDOWN_THRESHOLD = -0.05 # -5% 이하 경고
|
||||
|
||||
|
||||
def _detect_drift_alerts(trades: List["TradeHistory"]) -> List[Dict[str, Any]]:
|
||||
"""전략 드리프트 감지 — 4가지 항목을 점검하여 경고 리스트 반환.
|
||||
|
||||
감지 항목:
|
||||
1. rolling_win_rate: 최근 20거래 승률 < 30%
|
||||
2. rolling_sharpe: 최근 20거래일 Sharpe <= 0
|
||||
3. loss_streak: 최근 거래 중 5연패 이상
|
||||
4. mdd_acceleration: 최근 일별 drawdown이 -5% 이하
|
||||
|
||||
Returns:
|
||||
[{"type": str, "value": float, "threshold": float, "message": str}, ...]
|
||||
"""
|
||||
alerts: List[Dict[str, Any]] = []
|
||||
|
||||
if not trades:
|
||||
return alerts
|
||||
|
||||
pl_rates = [_to_float(t.profit_loss_rate) for t in trades]
|
||||
|
||||
# ── 1. 롤링 승률 하락 ─────────────────────────────────────────────────
|
||||
recent_rates = pl_rates[-_DRIFT_ROLLING_WINDOW:]
|
||||
if len(recent_rates) >= _DRIFT_ROLLING_WINDOW:
|
||||
recent_wins = sum(1 for r in recent_rates if r > 0)
|
||||
rolling_wr = recent_wins / len(recent_rates)
|
||||
if rolling_wr < _DRIFT_WIN_RATE_THRESHOLD:
|
||||
alerts.append({
|
||||
"type": "rolling_win_rate",
|
||||
"value": round(rolling_wr, 4),
|
||||
"threshold": _DRIFT_WIN_RATE_THRESHOLD,
|
||||
"message": f"최근 {_DRIFT_ROLLING_WINDOW}거래 승률 {rolling_wr*100:.1f}% (임계값 {_DRIFT_WIN_RATE_THRESHOLD*100:.0f}%)",
|
||||
})
|
||||
|
||||
# ── 2. 롤링 Sharpe 하락 ───────────────────────────────────────────────
|
||||
daily_pnl = _build_daily_pnl_series(trades)
|
||||
daily_returns = _daily_pnl_to_returns(daily_pnl)
|
||||
recent_returns = daily_returns[-_DRIFT_ROLLING_WINDOW:]
|
||||
if len(recent_returns) >= _DRIFT_ROLLING_WINDOW:
|
||||
rolling_sharpe = _calc_sharpe_ratio(recent_returns)
|
||||
if rolling_sharpe is not None and rolling_sharpe <= _DRIFT_SHARPE_THRESHOLD:
|
||||
alerts.append({
|
||||
"type": "rolling_sharpe",
|
||||
"value": rolling_sharpe,
|
||||
"threshold": _DRIFT_SHARPE_THRESHOLD,
|
||||
"message": f"최근 {_DRIFT_ROLLING_WINDOW}거래일 Sharpe {rolling_sharpe:.2f} (임계값 {_DRIFT_SHARPE_THRESHOLD:.1f})",
|
||||
})
|
||||
|
||||
# ── 3. 연속 손실 스트릭 ────────────────────────────────────────────────
|
||||
# 최근 거래부터 역순으로 현재 연속 손실 카운트
|
||||
current_streak = 0
|
||||
for r in reversed(pl_rates):
|
||||
if r < 0:
|
||||
current_streak += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if current_streak >= _DRIFT_LOSS_STREAK_THRESHOLD:
|
||||
alerts.append({
|
||||
"type": "loss_streak",
|
||||
"value": current_streak,
|
||||
"threshold": _DRIFT_LOSS_STREAK_THRESHOLD,
|
||||
"message": f"현재 {current_streak}연패 진행 중 (임계값 {_DRIFT_LOSS_STREAK_THRESHOLD}연패)",
|
||||
})
|
||||
|
||||
# ── 4. MDD 가속 (최근 drawdown 체크) ──────────────────────────────────
|
||||
if len(daily_returns) >= 5:
|
||||
# 최근 equity curve에서 drawdown 계산
|
||||
equity = 1.0
|
||||
equities = [equity]
|
||||
for r in daily_returns:
|
||||
equity *= (1.0 + r)
|
||||
equities.append(equity)
|
||||
|
||||
# 최근 5거래일의 drawdown 확인
|
||||
recent_eq = equities[-6:] # 최근 5일분 + 직전 1일
|
||||
if len(recent_eq) >= 2:
|
||||
recent_peak = max(recent_eq)
|
||||
recent_low = min(recent_eq)
|
||||
if recent_peak > 0:
|
||||
recent_dd = (recent_low - recent_peak) / recent_peak
|
||||
if recent_dd <= _DRIFT_DRAWDOWN_THRESHOLD:
|
||||
alerts.append({
|
||||
"type": "mdd_acceleration",
|
||||
"value": round(recent_dd, 4),
|
||||
"threshold": _DRIFT_DRAWDOWN_THRESHOLD,
|
||||
"message": f"최근 drawdown {recent_dd*100:.2f}% (임계값 {_DRIFT_DRAWDOWN_THRESHOLD*100:.1f}%)",
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 핵심 분석 함수
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -86,6 +360,11 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
||||
"by_exit_type": {청산 유형별 통계},
|
||||
"worst_stocks": [최대 손실 종목 Top5],
|
||||
"best_stocks": [최대 수익 종목 Top5],
|
||||
"sharpe_ratio": float | None (연환산, Rf=3%),
|
||||
"sortino_ratio": float | None (하방편차만),
|
||||
"calmar_ratio": float | None (CAGR / |MDD|),
|
||||
"daily_returns_count": int (일별 수익률 데이터 수),
|
||||
"drift_alerts": [{type, value, threshold, message}, ...],
|
||||
"period_days": N,
|
||||
"generated_at": ISO 문자열
|
||||
}
|
||||
@@ -111,6 +390,11 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
||||
"by_exit_type": {},
|
||||
"worst_stocks": [],
|
||||
"best_stocks": [],
|
||||
"sharpe_ratio": None,
|
||||
"sortino_ratio": None,
|
||||
"calmar_ratio": None,
|
||||
"daily_returns_count": 0,
|
||||
"drift_alerts": [],
|
||||
"period_days": days,
|
||||
"total_trades": 0,
|
||||
"generated_at": datetime.utcnow().isoformat(),
|
||||
@@ -210,6 +494,12 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
||||
worst_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[:5]]
|
||||
best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[-5:][::-1]]
|
||||
|
||||
# ── 리스크 조정 수익률 지표 ────────────────────────────────────────────
|
||||
risk_metrics = _calc_risk_adjusted_metrics(trades)
|
||||
|
||||
# ── 전략 드리프트 감지 ─────────────────────────────────────────────────
|
||||
drift_alerts = _detect_drift_alerts(trades)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"by_strategy": strategy_stats,
|
||||
@@ -217,6 +507,11 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
||||
"by_exit_type": exit_stats,
|
||||
"worst_stocks": worst_stocks,
|
||||
"best_stocks": best_stocks,
|
||||
"sharpe_ratio": risk_metrics["sharpe_ratio"],
|
||||
"sortino_ratio": risk_metrics["sortino_ratio"],
|
||||
"calmar_ratio": risk_metrics["calmar_ratio"],
|
||||
"daily_returns_count": risk_metrics["daily_returns_count"],
|
||||
"drift_alerts": drift_alerts,
|
||||
"period_days": days,
|
||||
"total_trades": len(trades),
|
||||
"generated_at": datetime.utcnow().isoformat(),
|
||||
@@ -302,6 +597,25 @@ def build_insights(perf: Dict[str, Any]) -> List[Dict[str, str]]:
|
||||
if sl_ratio > 0.5:
|
||||
insights.append({"level": "warning", "message": f"손절 청산 비율 {sl_ratio*100:.1f}% — 전체 청산의 절반 이상이 손절입니다. 진입 타이밍 재검토."})
|
||||
|
||||
# ── 리스크 조정 지표 인사이트 ──────────────────────────────────────────
|
||||
sharpe = perf.get("sharpe_ratio")
|
||||
if sharpe is not None:
|
||||
if sharpe < 0:
|
||||
insights.append({"level": "danger", "message": f"Sharpe Ratio {sharpe:.2f} — 무위험이자율(3%) 대비 음의 초과수익입니다. 전략 전면 재검토 필요."})
|
||||
elif sharpe < 0.5:
|
||||
insights.append({"level": "warning", "message": f"Sharpe Ratio {sharpe:.2f} — 낮은 수준입니다. 리스크 대비 수익 개선이 필요합니다."})
|
||||
elif sharpe >= 1.5:
|
||||
insights.append({"level": "info", "message": f"Sharpe Ratio {sharpe:.2f} — 우수한 리스크 조정 수익률입니다."})
|
||||
|
||||
calmar = perf.get("calmar_ratio")
|
||||
if calmar is not None and calmar < 1.0:
|
||||
insights.append({"level": "warning", "message": f"Calmar Ratio {calmar:.2f} — MDD 대비 수익률이 낮습니다. 낙폭 관리 강화 필요."})
|
||||
|
||||
# ── 드리프트 경고 인사이트 ─────────────────────────────────────────────
|
||||
drift_alerts = perf.get("drift_alerts", [])
|
||||
for alert in drift_alerts:
|
||||
insights.append({"level": "danger", "message": f"[드리프트 감지] {alert['message']}"})
|
||||
|
||||
return insights
|
||||
|
||||
|
||||
|
||||
@@ -654,8 +654,8 @@ async def _execute_buy(
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used:
|
||||
_adaptive_position_cap = _adp.position_size
|
||||
# Crisis 모드: 신규 진입 차단
|
||||
if _adp.trading_mode == "crisis" and _adp.position_size <= 0:
|
||||
# Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.5)
|
||||
if _adp.trading_mode == "crisis" and _adp.max_daily_trades <= 0:
|
||||
return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -5,6 +5,7 @@ us_stock_strategy의 매수/매도 신호를 과거 데이터에 적용하여
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import random
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
@@ -25,6 +26,27 @@ logger = logging.getLogger(__name__)
|
||||
US_COMMISSION_RATE = 0.001 # 해외 주식 수수료 약 0.1% (매수+매도 각각)
|
||||
RISK_FREE_RATE = 0.04 # 무위험 수익률 4% (연간, USD 기준)
|
||||
TRADING_DAYS_PER_YEAR = 252
|
||||
US_SLIPPAGE_PCT = 0.0005 # +/-0.05% (미국 주식은 유동성이 높아 슬리피지가 낮음)
|
||||
|
||||
|
||||
def _apply_us_buy_slippage(price: float, seed: Optional[int] = None) -> float:
|
||||
"""매수 시 슬리피지: 시장가 주문 시 약간 높은 가격에 체결
|
||||
|
||||
미국 주식은 유동성이 높아 국내(0.1%) 대비 낮은 0~0.05% 적용.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
slip = rng.uniform(0, US_SLIPPAGE_PCT)
|
||||
return price * (1 + slip)
|
||||
|
||||
|
||||
def _apply_us_sell_slippage(price: float, seed: Optional[int] = None) -> float:
|
||||
"""매도 시 슬리피지: 시장가 주문 시 약간 낮은 가격에 체결
|
||||
|
||||
미국 주식은 유동성이 높아 국내(0.1%) 대비 낮은 0~0.05% 적용.
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
slip = rng.uniform(0, US_SLIPPAGE_PCT)
|
||||
return price * (1 - slip)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -229,6 +251,10 @@ async def run_us_backtest(
|
||||
indicators = _calculate_indicators(daily_data)
|
||||
|
||||
# 3) 시뮬레이션 실행
|
||||
#
|
||||
# [Look-ahead bias 수정]
|
||||
# T일 close로 신호 생성 -> T+1일 open + 슬리피지로 체결.
|
||||
# 마지막 봉 신호는 다음 봉이 없으므로 무시합니다.
|
||||
cash = initial_capital_usd
|
||||
position_qty = 0
|
||||
position_avg_price = 0.0
|
||||
@@ -236,96 +262,114 @@ async def run_us_backtest(
|
||||
trades: List[Dict] = []
|
||||
equity_curve: List[Dict] = []
|
||||
|
||||
# 전일 발생한 신호를 저장 (T일 close 신호 -> T+1일 open 체결)
|
||||
pending_buy_signal: Optional[Dict] = None
|
||||
pending_sell_signal: Optional[str] = None
|
||||
|
||||
for i in range(20, len(daily_data)): # 지표 계산에 최소 20일 필요
|
||||
bar = daily_data[i]
|
||||
ind = indicators[i]
|
||||
close = bar["close"]
|
||||
bar_date = bar["date"]
|
||||
bar_open = bar["open"]
|
||||
|
||||
# 포지션 보유 시: 매도 조건 체크
|
||||
# -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 --
|
||||
if pending_sell_signal is not None and position_qty > 0:
|
||||
exec_price = _apply_us_sell_slippage(bar_open)
|
||||
amount = position_qty * exec_price
|
||||
commission = amount * US_COMMISSION_RATE
|
||||
pnl = (exec_price - position_avg_price) * position_qty - commission
|
||||
pnl_pct = ((exec_price - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
|
||||
|
||||
trade = {
|
||||
"date": bar_date,
|
||||
"action": "SELL",
|
||||
"ticker": ticker,
|
||||
"price": round(exec_price, 4),
|
||||
"quantity": position_qty,
|
||||
"amount": round(amount, 2),
|
||||
"commission": round(commission, 2),
|
||||
"reason": pending_sell_signal,
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
}
|
||||
trades.append(trade)
|
||||
cash += amount - commission
|
||||
position_qty = 0
|
||||
position_avg_price = 0.0
|
||||
position_high = 0.0
|
||||
pending_sell_signal = None
|
||||
|
||||
if pending_buy_signal is not None and position_qty == 0:
|
||||
exec_price = _apply_us_buy_slippage(bar_open)
|
||||
buy_amount = min(cash * 0.95, cash)
|
||||
if buy_amount > 10:
|
||||
quantity = int(buy_amount / exec_price)
|
||||
if quantity > 0:
|
||||
cost = quantity * exec_price
|
||||
commission = cost * US_COMMISSION_RATE
|
||||
total_cost = cost + commission
|
||||
|
||||
if total_cost <= cash:
|
||||
cash -= total_cost
|
||||
position_qty = quantity
|
||||
position_avg_price = exec_price
|
||||
position_high = exec_price
|
||||
|
||||
trade = {
|
||||
"date": bar_date,
|
||||
"action": "BUY",
|
||||
"ticker": ticker,
|
||||
"price": round(exec_price, 4),
|
||||
"quantity": quantity,
|
||||
"amount": round(cost, 2),
|
||||
"commission": round(commission, 2),
|
||||
"reason": pending_buy_signal.get("reason", "buy_signal"),
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0,
|
||||
}
|
||||
trades.append(trade)
|
||||
pending_buy_signal = None
|
||||
|
||||
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
|
||||
is_last_bar = (i == len(daily_data) - 1)
|
||||
|
||||
# 트레일링 스탑용 최고가 갱신 (close 기준)
|
||||
if position_qty > 0:
|
||||
# 트레일링 스탑용 최고가 갱신
|
||||
if close > position_high:
|
||||
position_high = close
|
||||
|
||||
sell_signal = _check_backtest_sell(
|
||||
close=close,
|
||||
avg_price=position_avg_price,
|
||||
high_since_entry=position_high,
|
||||
rsi=ind.get("rsi", 50),
|
||||
bb_middle=ind.get("bb_middle", close),
|
||||
bb_upper=ind.get("bb_upper", close),
|
||||
)
|
||||
if not is_last_bar:
|
||||
# 매도 신호 체크
|
||||
if position_qty > 0:
|
||||
sell_signal = _check_backtest_sell(
|
||||
close=close,
|
||||
avg_price=position_avg_price,
|
||||
high_since_entry=position_high,
|
||||
rsi=ind.get("rsi", 50),
|
||||
bb_middle=ind.get("bb_middle", close),
|
||||
bb_upper=ind.get("bb_upper", close),
|
||||
)
|
||||
if sell_signal:
|
||||
pending_sell_signal = sell_signal
|
||||
|
||||
if sell_signal:
|
||||
amount = position_qty * close
|
||||
commission = amount * US_COMMISSION_RATE
|
||||
pnl = (close - position_avg_price) * position_qty - commission
|
||||
pnl_pct = ((close - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
|
||||
# 매수 신호 체크
|
||||
if position_qty == 0 and pending_sell_signal is None:
|
||||
prev_rsi = indicators[i - 3].get("rsi") if i >= 3 else None
|
||||
|
||||
trade = {
|
||||
"date": bar_date,
|
||||
"action": "SELL",
|
||||
"ticker": ticker,
|
||||
"price": close,
|
||||
"quantity": position_qty,
|
||||
"amount": round(amount, 2),
|
||||
"commission": round(commission, 2),
|
||||
"reason": sell_signal,
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
}
|
||||
trades.append(trade)
|
||||
cash += amount - commission
|
||||
position_qty = 0
|
||||
position_avg_price = 0.0
|
||||
position_high = 0.0
|
||||
|
||||
# 포지션 없을 때: 매수 조건 체크
|
||||
if position_qty == 0:
|
||||
prev_rsi = indicators[i - 3].get("rsi") if i >= 3 else None
|
||||
|
||||
buy_signal = check_us_buy_signal(
|
||||
rsi=ind.get("rsi", 50),
|
||||
close=close,
|
||||
bb_upper=ind.get("bb_upper", close),
|
||||
bb_middle=ind.get("bb_middle", close),
|
||||
bb_lower=ind.get("bb_lower", close),
|
||||
avg_volume_20d=ind.get("avg_volume_20d", 0),
|
||||
current_volume=bar.get("volume", 0),
|
||||
current_price_usd=close,
|
||||
rsi_prev=prev_rsi,
|
||||
)
|
||||
|
||||
if buy_signal:
|
||||
# 전체 자본의 최대 95% 투입 (여유 현금 확보)
|
||||
buy_amount = min(cash * 0.95, cash)
|
||||
if buy_amount > 10: # 최소 $10 이상
|
||||
quantity = int(buy_amount / close)
|
||||
if quantity > 0:
|
||||
cost = quantity * close
|
||||
commission = cost * US_COMMISSION_RATE
|
||||
total_cost = cost + commission
|
||||
|
||||
if total_cost <= cash:
|
||||
cash -= total_cost
|
||||
position_qty = quantity
|
||||
position_avg_price = close
|
||||
position_high = close
|
||||
|
||||
trade = {
|
||||
"date": bar_date,
|
||||
"action": "BUY",
|
||||
"ticker": ticker,
|
||||
"price": close,
|
||||
"quantity": quantity,
|
||||
"amount": round(cost, 2),
|
||||
"commission": round(commission, 2),
|
||||
"reason": buy_signal.get("reason", "buy_signal"),
|
||||
"pnl": 0.0,
|
||||
"pnl_pct": 0.0,
|
||||
}
|
||||
trades.append(trade)
|
||||
buy_signal = check_us_buy_signal(
|
||||
rsi=ind.get("rsi", 50),
|
||||
close=close,
|
||||
bb_upper=ind.get("bb_upper", close),
|
||||
bb_middle=ind.get("bb_middle", close),
|
||||
bb_lower=ind.get("bb_lower", close),
|
||||
avg_volume_20d=ind.get("avg_volume_20d", 0),
|
||||
current_volume=bar.get("volume", 0),
|
||||
current_price_usd=close,
|
||||
rsi_prev=prev_rsi,
|
||||
)
|
||||
if buy_signal:
|
||||
pending_buy_signal = buy_signal
|
||||
|
||||
# 일별 자산 기록
|
||||
position_value = position_qty * close if position_qty > 0 else 0.0
|
||||
@@ -337,19 +381,20 @@ async def run_us_backtest(
|
||||
"position_value": round(position_value, 2),
|
||||
})
|
||||
|
||||
# 4) 미청산 포지션 강제 청산 (백테스트 종료)
|
||||
# 4) 미청산 포지션 강제 청산 (백테스트 종료, 슬리피지 적용)
|
||||
if position_qty > 0 and daily_data:
|
||||
last_close = daily_data[-1]["close"]
|
||||
amount = position_qty * last_close
|
||||
exec_price = _apply_us_sell_slippage(last_close)
|
||||
amount = position_qty * exec_price
|
||||
commission = amount * US_COMMISSION_RATE
|
||||
pnl = (last_close - position_avg_price) * position_qty - commission
|
||||
pnl_pct = ((last_close - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
|
||||
pnl = (exec_price - position_avg_price) * position_qty - commission
|
||||
pnl_pct = ((exec_price - position_avg_price) / position_avg_price * 100) if position_avg_price > 0 else 0.0
|
||||
|
||||
trades.append({
|
||||
"date": daily_data[-1]["date"],
|
||||
"action": "SELL",
|
||||
"ticker": ticker,
|
||||
"price": last_close,
|
||||
"price": round(exec_price, 4),
|
||||
"quantity": position_qty,
|
||||
"amount": round(amount, 2),
|
||||
"commission": round(commission, 2),
|
||||
|
||||
Reference in New Issue
Block a user