전략 취약점 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:
2026-04-03 11:41:52 +09:00
parent eda5d76af9
commit 5f0f839039
22 changed files with 299 additions and 132 deletions

View File

@@ -36,7 +36,7 @@ def upgrade() -> None:
]:
try:
batch_op.add_column(sa.Column(col_name, col_type, nullable=True))
except Exception:
except (sa.exc.OperationalError, sa.exc.ProgrammingError):
pass # 이미 존재하는 컬럼은 무시
# total_amount 백필

View File

@@ -11,6 +11,7 @@ Create Date: 2026-03-10
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, None] = "0001"
@@ -18,63 +19,26 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _safe_create_index(table, index_name, columns):
"""인덱스가 이미 존재하면 스킵."""
conn = op.get_bind()
result = conn.execute(sa.text(
"SELECT 1 FROM pg_indexes WHERE tablename=:t AND indexname=:i"
), {"t": table, "i": index_name})
if not result.fetchone():
op.create_index(index_name, table, columns)
def upgrade() -> None:
# ── trade_history 인덱스 ──────────────────────────────────────────────
with op.batch_alter_table("trade_history", schema=None) as batch_op:
batch_op.create_index(
"ix_trade_history_status_created_at",
["status", "created_at"],
)
batch_op.create_index(
"ix_trade_history_stock_order_created",
["stock_code", "order_type", "created_at"],
)
# ── market_data 인덱스 ────────────────────────────────────────────────
with op.batch_alter_table("market_data", schema=None) as batch_op:
batch_op.create_index(
"ix_market_data_stock_code_timestamp",
["stock_code", "timestamp"],
)
# ── stock_analysis 인덱스 ─────────────────────────────────────────────
with op.batch_alter_table("stock_analysis", schema=None) as batch_op:
batch_op.create_index(
"ix_stock_analysis_stock_code_analysis_date",
["stock_code", "analysis_date"],
)
# ── portfolio 인덱스 ──────────────────────────────────────────────────
with op.batch_alter_table("portfolio", schema=None) as batch_op:
batch_op.create_index(
"ix_portfolio_quantity",
["quantity"],
)
# ── active_trades 인덱스 ──────────────────────────────────────────────
with op.batch_alter_table("active_trades", schema=None) as batch_op:
batch_op.create_index(
"ix_active_trades_stock_code_status",
["stock_code", "status"],
)
batch_op.create_index(
"ix_active_trades_status",
["status"],
)
# ── watchlist_stocks 인덱스 ───────────────────────────────────────────
with op.batch_alter_table("watchlist_stocks", schema=None) as batch_op:
batch_op.create_index(
"ix_watchlist_stocks_is_active_stock_code",
["is_active", "stock_code"],
)
# ── claude_analysis_log 인덱스 ────────────────────────────────────────
with op.batch_alter_table("claude_analysis_log", schema=None) as batch_op:
batch_op.create_index(
"ix_claude_analysis_log_stock_code_created_at",
["stock_code", "created_at"],
)
_safe_create_index("trade_history", "ix_trade_history_status_created_at", ["status", "created_at"])
_safe_create_index("trade_history", "ix_trade_history_stock_order_created", ["stock_code", "order_type", "created_at"])
_safe_create_index("market_data", "ix_market_data_stock_code_timestamp", ["stock_code", "timestamp"])
_safe_create_index("stock_analysis", "ix_stock_analysis_stock_code_analysis_date", ["stock_code", "analysis_date"])
_safe_create_index("portfolio", "ix_portfolio_quantity", ["quantity"])
_safe_create_index("active_trades", "ix_active_trades_stock_code_status", ["stock_code", "status"])
_safe_create_index("active_trades", "ix_active_trades_status", ["status"])
_safe_create_index("watchlist_stocks", "ix_watchlist_stocks_is_active_stock_code", ["is_active", "stock_code"])
_safe_create_index("claude_analysis_log", "ix_claude_analysis_log_stock_code_created_at", ["stock_code", "created_at"])
def downgrade() -> None:

View File

@@ -5,6 +5,7 @@ Revises: 0004
Create Date: 2026-03-20
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = "0005"
@@ -14,8 +15,12 @@ depends_on = None
def upgrade() -> None:
# ix_active_trades_status는 0002에서 이미 생성됨 → stock_code 인덱스만 추가
op.create_index("ix_active_trades_stock_code", "active_trades", ["stock_code"])
conn = op.get_bind()
result = conn.execute(sa.text(
"SELECT 1 FROM pg_indexes WHERE tablename='active_trades' AND indexname='ix_active_trades_stock_code'"
))
if not result.fetchone():
op.create_index("ix_active_trades_stock_code", "active_trades", ["stock_code"])
def downgrade() -> None:

View File

@@ -16,10 +16,16 @@ depends_on = None
def upgrade() -> None:
op.add_column(
"news",
sa.Column("sentiment_score", sa.Numeric(5, 3), nullable=True),
)
conn = op.get_bind()
result = conn.execute(sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name='news' AND column_name='sentiment_score'"
))
if not result.fetchone():
op.add_column(
"news",
sa.Column("sentiment_score", sa.Numeric(5, 3), nullable=True),
)
def downgrade() -> None:

View File

@@ -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="성과 분석 기간"),

View File

@@ -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),

View File

@@ -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", [])
# 주/월봉 집계

View File

@@ -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)"""

View File

@@ -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 근사)

View File

@@ -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()

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -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:]

View File

@@ -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]:

View File

@@ -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"