diff --git a/CLAUDE.md b/CLAUDE.md index d4e0c30..1fac869 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,12 +102,12 @@ ## 5. 리스크 관리 규칙 ### 5.1 포지션 사이징 (Position Sizing) -- **최대 비중**: 종목당 총 자본의 **4%** (`config.py: MAX_POSITION_SIZE = 0.04`). +- **최대 비중**: 종목당 총 자본의 **6%** (`config.py: MAX_POSITION_SIZE = 0.06`, 백테스트 검증 반영). - **최대 보유 종목 수**: **20개** (`config.py: MAX_HOLDINGS_COUNT = 20`). - **레버리지**: 인버스/레버리지 ETF의 경우 총 자본의 최대 **20%**로 제한 (`config.py: LEVERAGE_ETF_MAX_RATIO = 0.20`). ### 5.2 손절(SL) 및 익절(TP) — 일봉/스윙 기준 -- **기본 손절 (SL Base)**: -6% (안정적 종목), -10% (변동성 종목). +- **기본 손절 (SL Base)**: -7% (안정적 종목), -10% (변동성 종목). (백테스트 검증: 기존 -6% → -7%) - **ATR 손절**: `진입가 - (2.5 * ATR)` (`config.py: ATR_STOP_MULTIPLIER = 2.5`). **기본 손절과 ATR 손절 중 타이트한(더 작은) 쪽 적용** — 코드에서 `min(stop_loss, atr_stop_pct)` 사용. - **익절 (TP)**: +2R 수익(리스크 대비 2배 수익) 도달 시 50% 분할 매도. - **트레일링 스탑 (Trailing Stop)**: 나머지 50% 물량에 대해 `고점 - (1.5 * ATR)` 적용. diff --git a/backend/alembic/versions/0001_initial_schema_columns.py b/backend/alembic/versions/0001_initial_schema_columns.py index e23eaa0..69d2cd4 100644 --- a/backend/alembic/versions/0001_initial_schema_columns.py +++ b/backend/alembic/versions/0001_initial_schema_columns.py @@ -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 백필 diff --git a/backend/alembic/versions/0002_add_performance_indexes.py b/backend/alembic/versions/0002_add_performance_indexes.py index 2f6f0b2..1d69630 100644 --- a/backend/alembic/versions/0002_add_performance_indexes.py +++ b/backend/alembic/versions/0002_add_performance_indexes.py @@ -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: diff --git a/backend/alembic/versions/0005_add_active_trade_indexes.py b/backend/alembic/versions/0005_add_active_trade_indexes.py index d874040..6cf3a9d 100644 --- a/backend/alembic/versions/0005_add_active_trade_indexes.py +++ b/backend/alembic/versions/0005_add_active_trade_indexes.py @@ -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: diff --git a/backend/alembic/versions/0006_add_news_sentiment_score.py b/backend/alembic/versions/0006_add_news_sentiment_score.py index e27ce4a..8d18f7d 100644 --- a/backend/alembic/versions/0006_add_news_sentiment_score.py +++ b/backend/alembic/versions/0006_add_news_sentiment_score.py @@ -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: diff --git a/backend/app/api/endpoints/analysis.py b/backend/app/api/endpoints/analysis.py index c089b7a..c3ede59 100644 --- a/backend/app/api/endpoints/analysis.py +++ b/backend/app/api/endpoints/analysis.py @@ -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="성과 분석 기간"), diff --git a/backend/app/api/endpoints/journals.py b/backend/app/api/endpoints/journals.py index 3419674..22c12cd 100644 --- a/backend/app/api/endpoints/journals.py +++ b/backend/app/api/endpoints/journals.py @@ -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), diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 1559985..750e78c 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -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", []) # 주/월봉 집계 diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 64c2b9f..750dfaf 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -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)""" diff --git a/backend/app/api/endpoints/us_trading.py b/backend/app/api/endpoints/us_trading.py index 4a3f1da..684206d 100644 --- a/backend/app/api/endpoints/us_trading.py +++ b/backend/app/api/endpoints/us_trading.py @@ -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 근사) diff --git a/backend/app/main.py b/backend/app/main.py index d07fadb..f78125f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/services/correlation_filter.py b/backend/app/services/correlation_filter.py index 872e007..0119728 100644 --- a/backend/app/services/correlation_filter.py +++ b/backend/app/services/correlation_filter.py @@ -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: diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index 8624bb8..52a852a 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -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 diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index b4e4b7b..3629b84 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -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 diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index b752a56..349a462 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -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: diff --git a/backend/app/services/kosdaq_scanner.py b/backend/app/services/kosdaq_scanner.py index e952005..e4c6bb2 100644 --- a/backend/app/services/kosdaq_scanner.py +++ b/backend/app/services/kosdaq_scanner.py @@ -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: diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index 2327aec..8ca0e70 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -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: diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index b1d8a60..722ab81 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -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 diff --git a/backend/app/services/ml_ensemble.py b/backend/app/services/ml_ensemble.py index 88e12b5..4b311f5 100644 --- a/backend/app/services/ml_ensemble.py +++ b/backend/app/services/ml_ensemble.py @@ -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:] diff --git a/backend/app/services/swing/entry_service.py b/backend/app/services/swing/entry_service.py index 9fafd4b..9c11895 100644 --- a/backend/app/services/swing/entry_service.py +++ b/backend/app/services/swing/entry_service.py @@ -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]: diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 91298d1..7c2ab97 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -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" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3cc623e..8285ebe 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -18,8 +18,14 @@ server { # index.html — 항상 최신 버전 제공 (Vite 해시 파일은 별도 캐시) location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate"; - add_header Pragma "no-cache"; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self';" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; } # SPA 라우팅 지원 @@ -27,7 +33,7 @@ server { try_files $uri $uri/ /index.html; } - # API 프록시 (선택사항) + # API 프록시 location /api { proxy_pass http://backend:8000; proxy_http_version 1.1; @@ -48,9 +54,10 @@ server { proxy_send_timeout 86400s; } - # 캐시 설정 + # 캐시 설정 (보안 헤더 유지) location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; - add_header Cache-Control "public, immutable"; + add_header Cache-Control "public, immutable" always; + add_header X-Content-Type-Options "nosniff" always; } }