전략 취약점 15건 수정 (코드 리뷰 기반)
## CRITICAL/HIGH (S1-S8) - S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지) - S2: ML scaler train-only fit (데이터 누수 제거) - S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소) - S4: EOD 청산 실패 시 재시도 + CRITICAL 로그 - S5: 일일 손실 한도에 미실현 P&L 포함 - S6: trailing stop 고점 매 사이클 갱신 - S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용) - S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간 ## MEDIUM (M1-M7) - M1: 스캐너 최소 거래량 100만→10만주 - M2: ADX 히스테리시스 18/22 (국면 플리핑 방지) - M3: 분할매도 최소 수익 1.0%→0.5% - M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소 - M5: 쿨다운 스트릭 부분매도 시 미변경 - M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지) - M7: RSI Wilder 스무딩으로 통일 ## 인프라/보안 - 인증 누락 엔드포인트 수정 (analysis, journals) - nginx 보안 헤더 location 블록 누락 수정 - Alembic 마이그레이션 IF NOT EXISTS 가드 추가 - SellRequest/UsOrderRequest 입력 검증 강화 - 차트 네이버 폴백 추가 (DB 데이터 없을 때) - 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집 - CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%) - MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수) - mdd_manager failed_count에서 skip 종목 제외 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from datetime import datetime, date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||
from app.core.security import require_api_key
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -108,7 +109,7 @@ async def _generate_journals_task(days: int = 7) -> Dict[str, Any]:
|
||||
return {"created": 0, "error": str(e)}
|
||||
|
||||
|
||||
@router.post("/generate-journals")
|
||||
@router.post("/generate-journals", dependencies=[Depends(require_api_key)])
|
||||
async def generate_journals(
|
||||
background_tasks: BackgroundTasks,
|
||||
days: int = Query(default=7, ge=1, le=30, description="최근 N일 내 미생성 거래 대상"),
|
||||
@@ -140,7 +141,7 @@ async def generate_journals(
|
||||
_optimization_running: bool = False
|
||||
|
||||
|
||||
@router.post("/optimize-strategy")
|
||||
@router.post("/optimize-strategy", dependencies=[Depends(require_api_key)])
|
||||
async def optimize_strategy(
|
||||
background_tasks: BackgroundTasks,
|
||||
days: int = Query(default=30, ge=7, le=180, description="성과 분석 기간"),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- POST /generate/{trade_id}: 특정 거래에 대한 일지 즉시 생성
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from app.core.security import require_api_key
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, func
|
||||
from typing import Optional
|
||||
@@ -92,7 +93,7 @@ async def get_journal_stats(db: AsyncSession = Depends(get_db)):
|
||||
return {"total": 0, "by_mood": {}, "by_strategy": {}}
|
||||
|
||||
|
||||
@router.post("/generate/{trade_id}")
|
||||
@router.post("/generate/{trade_id}", dependencies=[Depends(require_api_key)])
|
||||
async def generate_journal_for_trade_id(
|
||||
trade_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -329,7 +329,27 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
|
||||
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
|
||||
|
||||
if not db_data:
|
||||
# DB에 데이터 없으면 샘플 반환
|
||||
# DB에 데이터 없으면 네이버 차트 폴백
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_daily_chart
|
||||
naver_data = await get_naver_daily_chart(stock_code, days=limit * 2)
|
||||
if naver_data:
|
||||
db_data = [
|
||||
{
|
||||
"date": item.get("stck_bsop_date", ""),
|
||||
"open": float(item.get("stck_oprc", 0)),
|
||||
"high": float(item.get("stck_hgpr", 0)),
|
||||
"low": float(item.get("stck_lwpr", 0)),
|
||||
"close": float(item.get("stck_clpr", 0)),
|
||||
"volume": int(item.get("acml_vol", 0)),
|
||||
}
|
||||
for item in naver_data
|
||||
][-limit:]
|
||||
logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)")
|
||||
except Exception as e:
|
||||
logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}")
|
||||
|
||||
if not db_data:
|
||||
return _generate_sample_chart(stock_code, period, limit).get("data", [])
|
||||
|
||||
# 주/월봉 집계
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from typing import List, Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
import pytz
|
||||
|
||||
from app.core.database import get_db, AsyncSessionLocal
|
||||
@@ -37,8 +37,8 @@ KST = pytz.timezone('Asia/Seoul')
|
||||
# Request/Response Models
|
||||
class SellRequest(BaseModel):
|
||||
stock_code: str
|
||||
quantity: int
|
||||
price: Optional[int] = None
|
||||
quantity: int = Field(gt=0, description="매도 수량 (1 이상)")
|
||||
price: Optional[int] = Field(None, gt=0, description="지정가 (None이면 시장가)")
|
||||
|
||||
def _get_kst_now():
|
||||
"""현재 한국 시간 반환 (timezone-naive datetime)"""
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
- POST /us-trading/execute: 미국 주식 매수/매도 실행
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Literal, Optional, List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -16,8 +16,8 @@ router = APIRouter(prefix="/us-trading", tags=["US Trading"])
|
||||
|
||||
class UsOrderRequest(BaseModel):
|
||||
ticker: str
|
||||
order_type: str # "buy" or "sell"
|
||||
quantity: int
|
||||
order_type: Literal["buy", "sell"]
|
||||
quantity: int = Field(gt=0, description="주문 수량 (1 이상)")
|
||||
price: Optional[float] = None # None = market order attempt (limit 근사)
|
||||
|
||||
|
||||
|
||||
@@ -147,6 +147,48 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
# ── Alembic 자동 마이그레이션 (서버 이전 시 DB 스키마 자동 동기화) ──────
|
||||
try:
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from alembic import command as alembic_command
|
||||
import os
|
||||
alembic_ini = os.path.join(os.path.dirname(os.path.dirname(__file__)), "alembic.ini")
|
||||
if os.path.exists(alembic_ini):
|
||||
alembic_cfg = AlembicConfig(alembic_ini)
|
||||
alembic_cfg.set_main_option("script_location", os.path.join(os.path.dirname(os.path.dirname(__file__)), "alembic"))
|
||||
alembic_command.upgrade(alembic_cfg, "head")
|
||||
logger.info("✅ Alembic migration completed (upgrade head)")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Alembic migration 실패 (non-critical): {e}")
|
||||
|
||||
# ── KRX 일봉 데이터 초기 수집 (DB 비어있을 때만 백그라운드 실행) ──────
|
||||
try:
|
||||
from app.models.models import StockDailyPrice
|
||||
from sqlalchemy import select as _select, func as _func
|
||||
async with AsyncSessionLocal() as _check_session:
|
||||
_count = await _check_session.execute(
|
||||
_select(_func.count()).select_from(StockDailyPrice)
|
||||
)
|
||||
_total = _count.scalar() or 0
|
||||
if _total == 0 and settings.DATAGOKR_API_KEY:
|
||||
async def _initial_krx_collect():
|
||||
await asyncio.sleep(10) # 부팅 안정화 대기
|
||||
try:
|
||||
from collect_krx_data import collect_all
|
||||
logger.info("📥 [KRX] 초기 데이터 수집 시작 (stock_daily_prices 비어있음)...")
|
||||
await collect_all(update_mode=True)
|
||||
logger.info("✅ [KRX] 초기 데이터 수집 완료")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [KRX] 초기 데이터 수집 실패 (non-critical): {e}")
|
||||
asyncio.create_task(_initial_krx_collect())
|
||||
logger.info("📥 [KRX] stock_daily_prices 비어있음 — 백그라운드 초기 수집 예약됨")
|
||||
elif _total == 0:
|
||||
logger.warning("⚠️ [KRX] stock_daily_prices 비어있으나 DATAGOKR_API_KEY 미설정 — 네이버 차트 폴백 사용")
|
||||
else:
|
||||
logger.info(f"📊 [KRX] stock_daily_prices: {_total:,}행 확인")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [KRX] 초기 데이터 확인 실패 (non-critical): {e}")
|
||||
|
||||
try:
|
||||
from app.api.endpoints.day_trading import restore_positions_from_db
|
||||
await restore_positions_from_db()
|
||||
|
||||
@@ -185,18 +185,18 @@ async def check_sector_concentration(
|
||||
- reason: 거부 이유 (성공 시 None)
|
||||
"""
|
||||
# 진입 종목의 섹터 조회
|
||||
# [S7] 섹터 정보 없으면 "기타"로 분류하여 집중도 체크 적용
|
||||
sector = await get_stock_sector(stock_code, is_domestic=True)
|
||||
if not sector:
|
||||
# 섹터 정보 없으면 필터 통과 (정보 부족 시 기본적으로 허용)
|
||||
return True, None
|
||||
sector = "기타"
|
||||
|
||||
# 같은 섹터 종목들의 섹터 정보 조회
|
||||
# [S7] 보유 종목도 섹터 미확인 시 "기타" 적용
|
||||
sector_counts: Dict[str, int] = {}
|
||||
for holding in portfolio_holdings:
|
||||
if holding.quantity > 0:
|
||||
h_sector = await get_stock_sector(holding.stock_code, is_domestic=True)
|
||||
if h_sector:
|
||||
sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1
|
||||
h_sector = await get_stock_sector(holding.stock_code, is_domestic=True) or "기타"
|
||||
sector_counts[h_sector] = sector_counts.get(h_sector, 0) + 1
|
||||
|
||||
current_count = sector_counts.get(sector, 0)
|
||||
if current_count >= max_per_sector:
|
||||
|
||||
@@ -99,6 +99,7 @@ class OpeningRange:
|
||||
low: float
|
||||
total_volume: int
|
||||
avg_vol_per_min: float # 분당 평균 거래량 (ORB 기간)
|
||||
open_price: float = 0.0 # M4: 시가 (첫 ORB 봉의 open, gap 필터용)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -253,11 +254,14 @@ def calculate_opening_range(candles_1min: List[MinuteCandle]) -> Optional[Openin
|
||||
if len(orb) < 5:
|
||||
return None
|
||||
total_vol = sum(c.volume for c in orb)
|
||||
# M4: 시가 = ORB 기간 첫 봉의 open (gap 필터용)
|
||||
sorted_orb = sorted(orb, key=lambda c: c.time)
|
||||
return OpeningRange(
|
||||
high=max(c.high for c in orb),
|
||||
low=min(c.low for c in orb),
|
||||
total_volume=total_vol,
|
||||
avg_vol_per_min=total_vol / len(orb),
|
||||
open_price=sorted_orb[0].open,
|
||||
)
|
||||
|
||||
|
||||
@@ -302,6 +306,22 @@ def check_orb_signal(
|
||||
if not orb or current_price <= 0:
|
||||
return None
|
||||
|
||||
# M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부
|
||||
# prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용
|
||||
# orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주
|
||||
if orb.open_price > 0:
|
||||
_gap_ratio = abs(current_price - orb.open_price) / orb.open_price
|
||||
if _gap_ratio > 0.04:
|
||||
logger.debug("ORB skip %s: gap ratio %.2f%% > 4%%", stock_code, _gap_ratio * 100)
|
||||
return None
|
||||
|
||||
# M4: Range width filter — ORB 범위가 너무 좁으면 노이즈 돌파 위험
|
||||
if orb.low > 0:
|
||||
_range_width = (orb.high - orb.low) / orb.low
|
||||
if _range_width < 0.003: # 최소 0.3% 범위
|
||||
logger.debug("ORB skip %s: range width %.3f%% < 0.3%%", stock_code, _range_width * 100)
|
||||
return None
|
||||
|
||||
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
|
||||
return None
|
||||
|
||||
|
||||
@@ -73,8 +73,20 @@ async def check_and_exit_positions() -> list[dict]:
|
||||
return exits
|
||||
|
||||
# 일일 손실 한도 초과 시 전체 강제 청산
|
||||
if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl):
|
||||
logger.warning("[DayExit] 일일 손실 한도 초과 → 전체 강제 청산")
|
||||
# [S5] 미실현 손익도 포함하여 일일 손실 한도 판단
|
||||
unrealized_pnl = 0.0
|
||||
for _code, _pos in day_trading_state.positions.items():
|
||||
_entry = _pos.get("entry_price", 0)
|
||||
_qty = _pos.get("quantity", 0)
|
||||
_cur = _pos.get("current_price", _entry)
|
||||
if _entry > 0 and _qty > 0:
|
||||
unrealized_pnl += (_cur - _entry) * _qty
|
||||
total_daily_pnl = day_trading_state.daily_pnl + unrealized_pnl
|
||||
if is_daily_loss_exceeded(day_trading_state.last_total_assets, total_daily_pnl):
|
||||
logger.warning(
|
||||
f"[DayExit] 일일 손실 한도 초과 → 전체 강제 청산 "
|
||||
f"(실현={day_trading_state.daily_pnl:.0f}, 미실현={unrealized_pnl:.0f})"
|
||||
)
|
||||
return await force_close_all(reason="일일 손실 한도 초과")
|
||||
|
||||
# EOD 강제 청산 (15:20)
|
||||
@@ -131,10 +143,13 @@ async def _check_single_position(stock_code: str) -> dict | None:
|
||||
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완)
|
||||
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price
|
||||
new_highest = max(current_price, candle_high, highest_price)
|
||||
if new_highest > highest_price:
|
||||
async with day_trading_state.trade_lock:
|
||||
if stock_code in day_trading_state.positions:
|
||||
async with day_trading_state.trade_lock:
|
||||
if stock_code in day_trading_state.positions:
|
||||
if new_highest > highest_price:
|
||||
day_trading_state.positions[stock_code]["highest_price"] = new_highest
|
||||
# [S5] 미실현 손익 계산용 현재가 갱신
|
||||
day_trading_state.positions[stock_code]["current_price"] = current_price
|
||||
if new_highest > highest_price:
|
||||
highest_price = new_highest
|
||||
|
||||
# 청산 신호 체크
|
||||
@@ -231,7 +246,7 @@ async def _execute_exit(
|
||||
day_trading_state.positions[stock_code]["quantity"] = remaining
|
||||
day_trading_state.positions[stock_code]["target_price"] = float("inf")
|
||||
day_trading_state.positions[stock_code]["partial_sold"] = True
|
||||
day_trading_state.update_pnl(profit_loss)
|
||||
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
|
||||
|
||||
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
|
||||
if is_full_exit:
|
||||
@@ -295,6 +310,7 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]:
|
||||
list[dict]: 청산 실행 결과 목록
|
||||
"""
|
||||
results = []
|
||||
failed_codes: list[str] = []
|
||||
codes = list(day_trading_state.positions.keys())
|
||||
|
||||
for stock_code in codes:
|
||||
@@ -319,6 +335,33 @@ async def force_close_all(reason: str = "강제 청산") -> list[dict]:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DayExit] 강제 청산 오류 ({stock_code}): {e}", exc_info=True)
|
||||
failed_codes.append(stock_code)
|
||||
|
||||
# S4 fix: 실패 종목 1회 재시도
|
||||
if failed_codes:
|
||||
logger.warning(f"[DayExit] 강제 청산 실패 {len(failed_codes)}건 — 2초 후 재시도: {failed_codes}")
|
||||
await asyncio.sleep(2.0)
|
||||
still_failed: list[str] = []
|
||||
for stock_code in failed_codes:
|
||||
pos = day_trading_state.get_position(stock_code)
|
||||
if not pos:
|
||||
continue
|
||||
try:
|
||||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||||
price = float(
|
||||
price_resp.get("output", {}).get("stck_prpr", 0) or 0
|
||||
) if price_resp else 0.0
|
||||
if price <= 0:
|
||||
price = float(pos.get("entry_price", 0))
|
||||
result = await _execute_exit(stock_code, pos, price, reason)
|
||||
if result:
|
||||
results.append(result)
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.critical(f"[DayExit] 강제 청산 재시도 실패 ({stock_code}): {e} — 포지션 미청산 위험!", exc_info=True)
|
||||
still_failed.append(stock_code)
|
||||
if still_failed:
|
||||
logger.critical(f"[DayExit] 최종 미청산 종목: {still_failed} — 수동 청산 필요!")
|
||||
|
||||
logger.info(f"[DayExit] 강제 청산 완료: {len(results)}건 ({reason})")
|
||||
return results
|
||||
|
||||
@@ -153,20 +153,26 @@ class DayTradingState:
|
||||
self.portfolio_cache_dirty = True
|
||||
return pos
|
||||
|
||||
def update_pnl(self, realized: float) -> None:
|
||||
def update_pnl(self, realized: float, is_full_close: bool = True) -> None:
|
||||
"""실현 손익 누적 및 연속 손실 카운트 업데이트.
|
||||
|
||||
손실 시 loss_streak_today 증가, 이익 시 리셋.
|
||||
3연패 이상이면 is_cooldown_active()가 True 반환.
|
||||
|
||||
연속 손실 카운트는 전량 매도(is_full_close=True)일 때만 업데이트.
|
||||
분할 매도(is_full_close=False)나 손익분기(realized==0)는 카운트에 영향 없음.
|
||||
|
||||
Args:
|
||||
realized: 실현 손익 (원). 양수=이익, 음수=손실
|
||||
is_full_close: 전량 매도 여부. False이면 streak 카운트 미변경
|
||||
"""
|
||||
self.daily_pnl += realized
|
||||
if not is_full_close:
|
||||
return
|
||||
if realized < 0:
|
||||
self.loss_streak_today += 1
|
||||
else:
|
||||
# 이익 발생 시 연속 손실 카운트 리셋
|
||||
elif realized > 0:
|
||||
# 이익 발생 시 연속 손실 카운트 리셋 (손익분기 realized==0은 중립 — 리셋하지 않음)
|
||||
self.loss_streak_today = 0
|
||||
|
||||
def get_position(self, stock_code: str) -> dict | None:
|
||||
|
||||
@@ -18,8 +18,9 @@ logger = logging.getLogger(__name__)
|
||||
# 가격 필터 (CLAUDE.md 섹션 6.4)
|
||||
MIN_PRICE_KRW = 5_000
|
||||
|
||||
# 전일 거래량 최솟값: 100만주 미만 종목 제외 (유동성 부족)
|
||||
MIN_DAILY_VOLUME = 1_000_000
|
||||
# 전일 거래량 최솟값: 10만주 미만 종목 제외 (유동성 부족)
|
||||
# market_analyzer.py와 동일 수준. LIQUIDITY_PERCENTILE=0.80 이 추가 필터 역할.
|
||||
MIN_DAILY_VOLUME = 100_000
|
||||
|
||||
# 거래대금 상위 20% 임계값 (하위 80% 제외)
|
||||
LIQUIDITY_PERCENTILE = 0.80
|
||||
@@ -155,8 +156,13 @@ class KosdaqScanner:
|
||||
pct_vs_lower = (td.close - bb_lower) / bb_lower * 100
|
||||
volume_ratio = td.volume / volume_ma20
|
||||
|
||||
rsi_score = max(0.0, (25.0 - rsi14) / 25.0)
|
||||
bb_score = min(max(0.0, (0.0 - pct_vs_lower) / 3.0), 1.0) # 상한 1.0 — 폭락 종목 점수 왜곡 방지
|
||||
# [S8] RSI 점수: config의 MEAN_REVERSION_RSI_THRESHOLD(46.0) 기준으로 정규화
|
||||
from app.core.config import settings as _settings
|
||||
_rsi_threshold = _settings.MEAN_REVERSION_RSI_THRESHOLD # 46.0
|
||||
rsi_score = max(0.0, (_rsi_threshold - rsi14) / _rsi_threshold)
|
||||
# [S8] BB 점수: CLAUDE.md 6.2 기준 -3%~+1.5% 구간 보상
|
||||
# pct_vs_lower <= -3%: 최대 점수 1.0, +1.5% 이상: 점수 0.0
|
||||
bb_score = min(max(0.0, (1.5 - pct_vs_lower) / 4.5), 1.0)
|
||||
volume_score = min(volume_ratio, 2.0)
|
||||
total_score = rsi_score * 0.5 + bb_score * 0.3 + volume_score * 0.2
|
||||
|
||||
@@ -169,8 +175,8 @@ class KosdaqScanner:
|
||||
rsi_turning = rsi_prev is not None and rsi14 > rsi_prev
|
||||
|
||||
reason_parts = []
|
||||
if rsi14 <= 25:
|
||||
reason_parts.append(f"RSI({rsi14:.1f})≤25")
|
||||
if rsi14 <= _rsi_threshold:
|
||||
reason_parts.append(f"RSI({rsi14:.1f})≤{_rsi_threshold:.0f}")
|
||||
if pct_vs_lower <= 0:
|
||||
reason_parts.append(f"BB하단({pct_vs_lower:.1f}%)")
|
||||
if volume_ratio >= 1.5:
|
||||
|
||||
@@ -217,6 +217,7 @@ class MDDManager:
|
||||
holdings = sorted(holdings, key=lambda h: (not _is_leverage(h),))
|
||||
|
||||
results = []
|
||||
skipped = 0
|
||||
for h in holdings:
|
||||
stock_code = h.get("pdno", "")
|
||||
stock_name = h.get("prdt_name", stock_code)
|
||||
@@ -224,6 +225,7 @@ class MDDManager:
|
||||
current_price = float(h.get("prpr", 0) or 0)
|
||||
|
||||
if quantity <= 0 or current_price <= 0:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sell_qty = max(int(quantity * reduce_pct), 1)
|
||||
@@ -243,16 +245,9 @@ class MDDManager:
|
||||
profit_loss = (current_price - avg_price) * sell_qty
|
||||
profit_loss_rate = ((current_price - avg_price) / avg_price * 100) if avg_price > 0 else 0.0
|
||||
|
||||
# [BUG FIX] loss_tracker 기록 — MDD 강제매도도 연속 손실 추적 대상
|
||||
try:
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
is_loss = profit_loss_rate < 0
|
||||
await loss_tracker.record_trade_result_safe(
|
||||
stock_code, is_loss
|
||||
)
|
||||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||||
except Exception as lt_err:
|
||||
logger.warning(f"[MDD] loss_tracker 기록 실패 ({stock_code}): {lt_err}")
|
||||
# MDD 강제매도는 시스템적(systemic) 리스크 축소이며,
|
||||
# 개별 종목의 전략 실패가 아니므로 loss_tracker에 기록하지 않음.
|
||||
# 기록 시 양호한 종목이 MDD 축소로 인해 false ban될 수 있음.
|
||||
|
||||
await enqueue_trade_history({
|
||||
"stock_code": stock_code,
|
||||
@@ -304,7 +299,7 @@ class MDDManager:
|
||||
except Exception as e:
|
||||
logger.error(f"[MDD] 축소 매도 실패 ({stock_code}): {e}")
|
||||
|
||||
failed_count = len(holdings) - len(results)
|
||||
failed_count = len(holdings) - len(results) - skipped
|
||||
if failed_count > 0 and executed_key is not None:
|
||||
# 매도 실패 종목이 있으면 플래그 리셋 → 다음 주기에 재시도
|
||||
async with self._lock:
|
||||
|
||||
@@ -38,6 +38,9 @@ from app.services.strategy_config_service import strategy_cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# M2: ADX hysteresis state — 직전 regime을 기억하여 ADX 경계선에서 잦은 전환 방지
|
||||
_previous_regime: Optional["MarketRegime"] = None
|
||||
|
||||
|
||||
class MarketRegime(Enum):
|
||||
BULL = "bull"
|
||||
@@ -163,11 +166,10 @@ def detect_market_regime(index_closes: List[float],
|
||||
return MarketRegime.BEAR # 데이터 부족 시 보수적
|
||||
|
||||
# CLAUDE.md 6.1: ADX < 20 → SIDEWAYS (진입 차단)
|
||||
# S3 fix: ADX 값은 먼저 계산하되, slope도 함께 확인 후 판정
|
||||
_adx_value = None
|
||||
if highs is not None and lows is not None:
|
||||
adx = calculate_adx(highs, lows, index_closes)
|
||||
if adx is not None and adx < settings.MEAN_REVERSION_ADX_SIDEWAYS:
|
||||
logger.info("시장 국면: SIDEWAYS (ADX=%.1f < %.1f) — CLAUDE.md 6.1", adx, settings.MEAN_REVERSION_ADX_SIDEWAYS)
|
||||
return MarketRegime.SIDEWAYS
|
||||
_adx_value = calculate_adx(highs, lows, index_closes)
|
||||
|
||||
ma200_current = sum(index_closes[-settings.MEAN_REVERSION_MA200_PERIOD:]) / settings.MEAN_REVERSION_MA200_PERIOD
|
||||
# [#10] 기울기 비교 기간을 5일→20일로 확대하여 SIDEWAYS 과다판정 방지
|
||||
@@ -182,28 +184,64 @@ def detect_market_regime(index_closes: List[float],
|
||||
|
||||
# CLAUDE.md 6.1: 기울기 ≈ 0 → SIDEWAYS
|
||||
slope_ratio = abs(ma200_current - ma200_prev) / ma200_prev if ma200_prev > 0 else 0
|
||||
|
||||
# S3 fix: SIDEWAYS 판정은 ADX < threshold AND slope ≈ 0 모두 충족 시에만.
|
||||
# ADX < threshold이더라도 slope가 명확하면 BULL/BEAR로 분류한다.
|
||||
# M2: ADX hysteresis — enter SIDEWAYS at ADX < 18, exit SIDEWAYS at ADX > 22
|
||||
global _previous_regime
|
||||
_ADX_ENTER_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0 # 18
|
||||
_ADX_EXIT_SIDEWAYS = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0 # 22
|
||||
|
||||
if _adx_value is not None:
|
||||
_was_sideways = (_previous_regime == MarketRegime.SIDEWAYS)
|
||||
# hysteresis: 이미 SIDEWAYS면 ADX > 22 돌파해야 탈출, 아니면 ADX < 18 이하여야 진입
|
||||
_adx_triggers_sideways = (
|
||||
(_was_sideways and _adx_value < _ADX_EXIT_SIDEWAYS) or
|
||||
(not _was_sideways and _adx_value < _ADX_ENTER_SIDEWAYS)
|
||||
)
|
||||
if _adx_triggers_sideways:
|
||||
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
|
||||
logger.info("시장 국면: SIDEWAYS (ADX=%.1f, hysteresis=[enter<%.0f,exit>%.0f] AND slope≈0, ratio=%.5f) — CLAUDE.md 6.1",
|
||||
_adx_value, _ADX_ENTER_SIDEWAYS, _ADX_EXIT_SIDEWAYS, slope_ratio)
|
||||
_previous_regime = MarketRegime.SIDEWAYS
|
||||
return MarketRegime.SIDEWAYS
|
||||
else:
|
||||
logger.info("시장 국면: ADX=%.1f이나 slope 명확(ratio=%.5f) — BULL/BEAR로 분류", _adx_value, slope_ratio)
|
||||
|
||||
if slope_ratio < settings.MEAN_REVERSION_SLOPE_THRESHOLD:
|
||||
logger.info("시장 국면: SIDEWAYS (MA200 기울기 ≈ 0, ratio=%.5f) — CLAUDE.md 6.1", slope_ratio)
|
||||
_previous_regime = MarketRegime.SIDEWAYS
|
||||
return MarketRegime.SIDEWAYS
|
||||
|
||||
is_bull = index_closes[-1] > ma200_current and slope_positive
|
||||
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", "BULL" if is_bull else "BEAR", slope_ratio)
|
||||
return MarketRegime.BULL if is_bull else MarketRegime.BEAR
|
||||
result = MarketRegime.BULL if is_bull else MarketRegime.BEAR
|
||||
logger.info("시장 국면: %s (MA200 slope_ratio=%.5f, 20일 기준)", result.value.upper(), slope_ratio)
|
||||
_previous_regime = result
|
||||
return result
|
||||
|
||||
|
||||
def calculate_rsi(closes: List[float], period: int = 14) -> Optional[float]:
|
||||
"""RSI(Relative Strength Index) 계산"""
|
||||
"""RSI(Relative Strength Index) 계산 — Wilder 평활법
|
||||
|
||||
feature_engineering.compute_rsi()와 동일한 Wilder smoothing 사용:
|
||||
- 첫 period개: 단순 평균(seed)
|
||||
- 이후: avg = (prev_avg * (period-1) + current) / period
|
||||
"""
|
||||
if len(closes) < period + 1:
|
||||
return None
|
||||
|
||||
gains, losses = [], []
|
||||
for i in range(-period, 0):
|
||||
diff = closes[i] - closes[i - 1]
|
||||
gains.append(max(diff, 0))
|
||||
losses.append(max(-diff, 0))
|
||||
# 모든 price diff 계산
|
||||
diffs = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
||||
|
||||
# Seed: 첫 period개의 단순 평균
|
||||
avg_gain = sum(max(d, 0) for d in diffs[:period]) / period
|
||||
avg_loss = sum(max(-d, 0) for d in diffs[:period]) / period
|
||||
|
||||
# Wilder smoothing: 나머지 구간
|
||||
for i in range(period, len(diffs)):
|
||||
avg_gain = (avg_gain * (period - 1) + max(diffs[i], 0)) / period
|
||||
avg_loss = (avg_loss * (period - 1) + max(-diffs[i], 0)) / period
|
||||
|
||||
avg_gain = sum(gains) / period
|
||||
avg_loss = sum(losses) / period
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss
|
||||
@@ -301,6 +339,8 @@ def check_buy_signal(
|
||||
if not _adp_bb.fallback_used:
|
||||
# Normal 기준 0.30 대비 비율로 스케일링
|
||||
_adaptive_bb_scale = _adp_bb.bb_pct_b / 0.30 if _adp_bb.bb_pct_b > 0 else 1.0
|
||||
# S1 fix: BB 중심선(pct_b=0.5) 위 진입 방지 — 스케일 상한 1.3
|
||||
_adaptive_bb_scale = min(_adaptive_bb_scale, 1.3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -462,7 +502,7 @@ def check_exit_signal(
|
||||
min_profit_price = entry_price + 2.0 * atr # CLAUDE.md 6.3: 2×ATR
|
||||
_profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0
|
||||
# 최소 수익률 가드: 수수료(~0.4%)를 커버하지 못하면 익절하지 않음
|
||||
_MIN_PROFIT_FOR_EXIT = 1.0 # 최소 1% 수익이어야 익절 (수수료+세금 0.4% 커버 + 알파)
|
||||
_MIN_PROFIT_FOR_EXIT = 0.5 # 최소 0.5% 수익이어야 익절 (수수료+세금 ~0.3-0.4% 커버)
|
||||
if remaining_pct > threshold and _profit_pct >= _MIN_PROFIT_FOR_EXIT:
|
||||
rsi_exit = rsi >= _adaptive_rsi_partial
|
||||
bb_exit = close > bb_middle
|
||||
|
||||
@@ -246,10 +246,13 @@ class MLEnsemble:
|
||||
# 탭뷸러 피처 스케일링
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
self.scaler = StandardScaler()
|
||||
X_scaled = self.scaler.fit_transform(X_tabular)
|
||||
|
||||
# 학습/검증 분리 (시계열이므로 뒤쪽 20%를 검증으로)
|
||||
split_idx = int(len(X_scaled) * 0.8)
|
||||
split_idx = int(len(X_tabular) * 0.8)
|
||||
|
||||
# S2 fix: scaler는 학습 데이터에만 fit하여 data leakage 방지
|
||||
self.scaler.fit(X_tabular[:split_idx])
|
||||
X_scaled = self.scaler.transform(X_tabular)
|
||||
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
|
||||
y_train, y_val = y[:split_idx], y[split_idx:]
|
||||
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
|
||||
|
||||
@@ -37,9 +37,8 @@ from app.services.market_playbook import is_entry_allowed_by_playbook, get_posit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20이지만,
|
||||
# 스윙 전용으로 25개까지 허용 -- 데이트레이딩과 분리 운영)
|
||||
MAX_SWING_POSITIONS = 25
|
||||
# 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20)
|
||||
MAX_SWING_POSITIONS = 20
|
||||
|
||||
|
||||
async def execute_swing_entries(recommendations_response: dict, db: AsyncSession) -> list[dict]:
|
||||
|
||||
@@ -203,6 +203,15 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]:
|
||||
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct is not None else 1.0
|
||||
trailing_high = float(active_trade.highest_since_entry) if active_trade and active_trade.highest_since_entry else None
|
||||
|
||||
# [S6] 트레일링스탑 고점(highest_since_entry) 매 사이클 갱신
|
||||
if active_trade and latest_price > (trailing_high or 0):
|
||||
trailing_high = latest_price
|
||||
from app.services.db_worker import enqueue_active_trade_upsert
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": stock_code,
|
||||
"highest_since_entry": trailing_high,
|
||||
})
|
||||
|
||||
# ── 전략별 청산 분기 ────────────────────────────────────
|
||||
exit_signal = None
|
||||
_strategy = active_trade.strategy_name if active_trade else "swing"
|
||||
|
||||
Reference in New Issue
Block a user