diff --git a/CLAUDE.md b/CLAUDE.md index 73b4c71..24ea382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ - **-20%**: 전 포지션 50% 강제 축소, 레버리지/인버스 ETF 우선 청산 (FORCE_REDUCE) - 잔고가 peak 갱신 시 모든 MDD 플래그 리셋. -## 6. 전략 A: RSI+BB 평균 회귀 v2 (`mean_reversion_strategy`, `swing/`) — 스윙 자본 75% +## 6. 전략 A: RSI+BB 평균 회귀 v2 (`mean_reversion_strategy`, `swing/`) — 스윙 자본 최대 50%(`SWING_MAX_CAPITAL_RATIO`) - **시장 국면(일봉)**: BULL = 코스피200>MA200 & MA200 기울기>0 / BEAR = 코스피20020MA / BEAR: RSI<35 + 가격1.5×20MA + 반전캔들 필수 / 선제: 일봉 RSI<46 & 60분봉 RSI<30 & MA5 상향돌파 - **청산**: 50% 부분매도: RSI≥55 or 가격>BB중앙 or 수익>2×ATR / 전량: RSI≥70 or 가격>BB상단(99%) or 트레일링(고점−1.5×ATR) @@ -63,7 +63,7 @@ - 타겟: 일봉 RSI<40 / 진입: 15분 RSI<30 + BB하단 부근 + 종가>MA5(상향돌파) + 거래량>1.3×20MA(`SHORT_TERM_VOLUME_MULTIPLIER`) + ATR/close≥0.8%(`SHORT_TERM_MIN_ATR_RATIO`, 거래비용 커버) - 청산: 15분 RSI>55 or 수익>1.5×ATR(15분) / SL: `min(진입가−1.0×ATR, -2% 하드스탑)` — 타이트한(stop가 높은) 쪽. *§5.2 ATR 2.5×는 일봉/스윙 전용* -### 7.3 데이트레이딩 5분봉 (`day_trading_strategy`, `daytrading/`) — 자본 최대 25%(`DAY_TRADE_MAX_RATIO_DT`), **MDD -10% 시 비활성** +### 7.3 데이트레이딩 5분봉 (`day_trading_strategy`, `daytrading/`) — 자본 최대 **50%**(`DAY_TRADE_MAX_RATIO_DT`, 스윙:DT = 50:50, 스윙은 `SWING_MAX_CAPITAL_RATIO=0.50`로 상한), **MDD -10% 시 비활성** | 전략 | 진입 | SL | |------|------|-----| @@ -71,9 +71,10 @@ | VWAP | VWAP 지지 반등 + RSI(14)<40 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | | RSI_BB | RSI≤30 + 종가≤BB하단 + 거래량≥1.3× + 양봉 | `min(ATR×1.5, -2.5% 하드)` + 최소 1원 | -- 사이징: 종목당 4%(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 3종목(`DAY_TRADE_MAX_POSITIONS`), confidence 조정 `예산×(confidence/0.7)` +- 사이징: 종목당 6%(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 8종목(`DAY_TRADE_MAX_POSITIONS`), confidence 조정 `예산×(confidence/0.7)` - 청산: +2R 시 50% 분할→잔량 트레일링(ATR×1.0) / 진입 30분(6봉)간 +0.3% 미달 시 시간손절 / 5분 RSI≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산**(`DAY_TRADE_EOD_FLAT_*`) -- 제한: SIDEWAYS 진입 차단 / 점심(11:30~13:00) VWAP·RSI_BB 제한 / **14:30 이후 신규 마감** / 일일 -3% 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` +- 제한: CRASH/HALTED 국면 차단 / **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`, 점심 제한은 2026-09-22 제거) / 일일 -3% 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` +- 감시종목: 09:00에 당일 분석이 없으면 **최근 분석일 결과로 선채움**(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지) ## 8. Claude 지침 ### 8.1 체크포인트 @@ -99,8 +100,9 @@ | `MEAN_REVERSION_BEAR_RSI_THRESHOLD` | 35.0 | 30→35 | | `MEAN_REVERSION_RSI_EXIT_THRESHOLD` | 55.0 | 1차 익절, 50→55 | | `MEAN_REVERSION_RSI_FULL_EXIT` | 70.0 | 전량 청산, 65→70 | -| `DAY_TRADE_MAX_POSITIONS` | 3 | | -| `DAY_TRADE_ENTRY_CUTOFF` | 14:30 | 15:00→14:30 | +| `DAY_TRADE_MAX_POSITIONS` | 8 | 3→8 (50:50 배분) | +| `DAY_TRADE_ENTRY_CUTOFF` | 15:00 | 14:30→15:00 (시간대 완화) | +| `DAY_TRADE_MAX_RATIO_DT` / `SWING_MAX_CAPITAL_RATIO` | 0.50 / 0.50 | 스윙:DT 50:50 (2026-09-22) | | `CONSECUTIVE_LOSS_BAN_COUNT` | 3 | | ### 8.3 스윙 듀얼 전략 (3종 병행) @@ -120,4 +122,4 @@ | Defensive | 38 | 4% | -6% | ATR백분위>70 or 5일수익<-3% | | Crisis | 25 | 1.5% | -3% | ATR백분위>90 or 5일수익<-5% | -*마지막 업데이트: 2026-09-06 (문서 압축 최적화, 규칙 변경 없음)* +*마지막 업데이트: 2026-09-22 (스윙:DT 50:50, DT 시간대 제한 완화, ORB 감시목록 선채움)* diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3a5d8a6..ace92c3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -217,11 +217,12 @@ class Settings(BaseSettings): # 데이트레이딩(5분봉) 전략 (섹션 7.3) # ═════════════════════════════════════════════════════════════════════════════ DAY_TRADE_ENABLED: bool = True # 데이트레이딩 활성화 - DAY_TRADE_MAX_POSITIONS: int = 3 # 동시 최대 종목 수 - DAY_TRADE_CAPITAL_RATIO: float = 0.25 # 전체 자본 대비 최대 25% - DAY_TRADE_PER_STOCK_RATIO: float = 0.04 # 종목당 최대 4% - DAY_TRADE_MAX_CAPITAL_RATIO: float = 0.04 # 종목당 최대 4% 자본 - DAY_TRADE_MAX_RATIO_DT: float = 0.25 # 전체 데이트레이딩 최대 25% + DAY_TRADE_MAX_POSITIONS: int = 8 # 동시 최대 종목 수 (3→8, 2026-09-22 50:50 배분: 8×6%≈50%) + DAY_TRADE_CAPITAL_RATIO: float = 0.50 # 전체 자본 대비 최대 50% (0.25→0.50, 2026-09-22) + DAY_TRADE_PER_STOCK_RATIO: float = 0.06 # 종목당 최대 6% (0.04→0.06, MAX_POSITION_SIZE 상한과 동일) + DAY_TRADE_MAX_CAPITAL_RATIO: float = 0.06 # 종목당 최대 6% 자본 (0.04→0.06, 2026-09-22) + DAY_TRADE_MAX_RATIO_DT: float = 0.50 # 전체 데이트레이딩 최대 50% (0.25→0.50, 2026-09-22 스윙:DT = 50:50) + SWING_MAX_CAPITAL_RATIO: float = 0.50 # 스윙 전략 합산 최대 50% (신규, 2026-09-22 — 초과 시 스윙 신규 매수 차단) DAY_TRADE_STOP_LOSS_PCT: float = 0.02 # 종목 손절 -2% DAY_TRADE_DAILY_LOSS_LIMIT: float = -0.03 # 일일 손실 한도 -3% DAY_TRADE_EOD_FLAT_HOUR: int = 15 # EOD 강제 청산 시간 (15시) @@ -234,8 +235,8 @@ class Settings(BaseSettings): DAY_TRADE_TRAILING_ATR_MULT: float = 1.5 # 트레일링 ATR 배수 (1.0→1.5 whipsaw 방지) DAY_TRADE_ORB_BREAKOUT_MARGIN: float = 0.002 # ORB 돌파 최소 여유 +0.2% DAY_TRADE_COST_PCT: float = 0.005 # 수수료+슬리피지 합산 (매수+매도) - DAY_TRADE_ENTRY_CUTOFF_HOUR: int = 14 # 신규 진입 마감 시간 (CLAUDE.md 7.3: 14:30) - DAY_TRADE_ENTRY_CUTOFF_MIN: int = 30 # 신규 진입 마감 분 + DAY_TRADE_ENTRY_CUTOFF_HOUR: int = 15 # 신규 진입 마감 시간 (14:30→15:00, 2026-09-22 시간대 제한 완화) + DAY_TRADE_ENTRY_CUTOFF_MIN: int = 0 # 신규 진입 마감 분 DAY_TRADE_EOD_HOUR: int = 15 # EOD 강제 청산 시작 시간 DAY_TRADE_EOD_MIN: int = 20 # EOD 강제 청산 시작 분 DAY_TRADE_ORB_BREAKOUT_VOL_MULT: float = 1.5 # ORB 거래량 배수 (vs 분당평균) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index cbb26a5..0524deb 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -32,7 +32,7 @@ day_trading_strategy.py의 신호 함수를 재활용하고 """ import asyncio import logging -from datetime import datetime +from datetime import datetime, time as dtime import pytz @@ -64,19 +64,28 @@ _KST = pytz.timezone("Asia/Seoul") # day_trading.py의 _watchlist와 동기화되며, POST /api/daytrading/watchlist 또는 # populate_daytrading_watchlist()로 설정됨 _watchlist: dict[str, str] = {} +# True = populate_daytrading_watchlist()로 자동 구성된 목록 (스케줄러가 30분마다 갱신 가능) +# False = API로 수동 설정된 목록 (자동 갱신으로 덮어쓰지 않음) +_watchlist_auto: bool = False def set_watchlist(stocks: list[dict]) -> None: - """감시 종목 설정. + """감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외). Args: stocks: 종목 목록. 각 항목은 {"code": "005930", "name": "삼성전자"} 형태 """ - global _watchlist + global _watchlist, _watchlist_auto _watchlist = {s["code"]: s.get("name", s["code"]) for s in stocks} + _watchlist_auto = False logger.info(f"[DayEntry] 감시 종목 설정: {len(_watchlist)}개") +def is_watchlist_auto() -> bool: + """현재 감시 목록이 자동 구성(populate)된 것인지 여부.""" + return _watchlist_auto + + def get_watchlist() -> dict[str, str]: """현재 감시 종목 딕셔너리 복사본 반환. @@ -111,14 +120,30 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 """ from app.core.database import AsyncSessionLocal from app.models.models import StockAnalysis - from sqlalchemy import select + from sqlalchemy import select, func today = datetime.now(_KST).date() async with AsyncSessionLocal() as session: + # [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 → + # ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근 + # 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨. + latest_date = ( + await session.execute( + select(func.max(StockAnalysis.analysis_date)).where( + StockAnalysis.analysis_date <= today + ) + ) + ).scalar_one_or_none() + if latest_date is None: + logger.info("[DayEntry] stock_analysis 데이터 없음") + return 0 + if latest_date != today: + logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성") + result = await session.execute( select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) .where( - StockAnalysis.analysis_date == today, + StockAnalysis.analysis_date == latest_date, StockAnalysis.score >= min_score, StockAnalysis.rsi.isnot(None), StockAnalysis.current_price > 5000, @@ -129,11 +154,13 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 rows = result.all() if not rows: - logger.info(f"[DayEntry] stock_analysis에 오늘({today}) 데이터 없음") + logger.info(f"[DayEntry] stock_analysis({latest_date})에 조건 충족 종목 없음") return 0 stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows] set_watchlist(stocks) + global _watchlist_auto + _watchlist_auto = True logger.info(f"[DayEntry] 자동 watchlist 설정: {len(stocks)}개 (score>={min_score})") return len(stocks) @@ -233,13 +260,14 @@ async def scan_and_enter() -> list[dict]: except Exception as e: logger.info(f"[DayEntry] 시장 국면 조회 실패 (계속 진행): {e}") - # [P1] 시간대 제한 — 점심시간(11:30~13:00) VWAP/RSI_BB 제한, 14:30 이후 전략 진입 마감 + # [시간대 제한 완화 2026-09-22] 점심시간(11:30~13:00) VWAP/RSI_BB 제한 제거. + # 신규 진입 마감만 유지 — settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN (기본 15:00, + # EOD 15:20 강제청산 전 최소 20분 확보). 하드코딩 14:30 → 설정값. now_time = datetime.now(_KST).time() - _now = datetime.now(_KST) - _is_lunch_break = (_now.hour == 11 and _now.minute >= 30) or _now.hour == 12 - _is_late_session = now_time >= datetime.strptime("14:30", "%H:%M").time() - if _is_late_session: - logger.info("[DayEntry] ⛔ 14:30 이후 — 신규 진입 마감") + _is_lunch_break = False + _cutoff = dtime(settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN) + if now_time >= _cutoff: + logger.info(f"[DayEntry] ⛔ {_cutoff.strftime('%H:%M')} 이후 — 신규 진입 마감") return entries # 포지션 한도 체크 diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index 804fac1..3b7062d 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -32,7 +32,7 @@ from app.services.mdd_manager import mdd_manager from app.services.daytrading.position_manager import day_trading_state from app.services.daytrading.entry_service import ( collect_opening_ranges, scan_and_enter, - get_watchlist, populate_daytrading_watchlist, + get_watchlist, populate_daytrading_watchlist, is_watchlist_auto, ) from app.services.daytrading.exit_service import check_and_exit_positions, force_close_all @@ -111,7 +111,9 @@ async def daytrading_scheduler() -> None: if is_day_trade_entry_window(): global _last_wl_sync wl = get_watchlist() - if not wl and (time.time() - _last_wl_sync) > 1800: + # 30분마다 갱신 (비어 있을 때만 → 항상): 09:00에 전일 분석으로 채운 목록을 + # 당일 배치 완료 후 당일 분석 결과로 교체하기 위함 (ORB 캐시는 종목코드 기준 유지) + if not wl or (is_watchlist_auto() and (time.time() - _last_wl_sync) > 1800): _last_wl_sync = time.time() try: await populate_daytrading_watchlist() diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 1a5fd65..e3dd2f6 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -829,6 +829,30 @@ async def _execute_buy( if invest_amount <= 0: return {"action": "SKIP", "reason": "global_capital_guard", "stock": stock_name} + # [스윙:DT 자본 배분 2026-09-22] 스윙 합산 투자액이 총자산 × SWING_MAX_CAPITAL_RATIO 를 + # 넘지 않도록 상한. 스윙 투자액 = 주식 평가액 − 데이트레이딩 포지션 투자액. + # (스윙이 자본 91%를 점유해 DT 예산이 남지 않던 문제 → 50:50) + _swing_cap_ratio = float(getattr(settings, "SWING_MAX_CAPITAL_RATIO", 1.0) or 1.0) + if 0 < _swing_cap_ratio < 1 and total_assets > 0: + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + _dt_amount = sum( + float(p.get("entry_price", 0) or 0) * int(p.get("quantity", 0) or 0) + for p in _dts.positions.values() + ) + except Exception: + _dt_amount = 0.0 + _swing_invested = max(0.0, total_assets - available_cash - _dt_amount) + _swing_room = total_assets * _swing_cap_ratio - _swing_invested + if invest_amount > _swing_room: + invest_amount = max(0.0, _swing_room) + if invest_amount <= 0: + logger.info( + f"[TRADE] {stock_name}: 스윙 자본 상한 도달 " + f"({_swing_invested/total_assets:.1%} ≥ {_swing_cap_ratio:.0%}) — 신규 매수 차단" + ) + return {"action": "SKIP", "reason": "swing_capital_cap", "stock": stock_name} + # ── 7.5. 패턴 메모리 기반 포지션 조정 ────────────────────────────────── # 종목별 과거 행동 패턴(지지/저항선, RSI 반등, 승률 등)을 기반으로 # 투자금액을 -30% ~ +30% 범위에서 동적 조정. diff --git a/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py b/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py new file mode 100644 index 0000000..d054c7e --- /dev/null +++ b/backend/tests/test_daytrading_orb_watchlist_and_capital_split.py @@ -0,0 +1,111 @@ +"""2026-09-22 변경 회귀 테스트: ORB 감시목록 전일 폴백 / 시간대 제한 완화 / 스윙:DT 50:50 자본 배분.""" +import asyncio +from datetime import date, datetime, timedelta, time as dtime +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.compiler import compiles + +from app.core.config import settings + + +@compiles(JSONB, "sqlite") +def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환 + return "JSON" +from app.models.models import Base, StockAnalysis +from app.services.daytrading import entry_service as es +from app.services.daytrading import scheduler as sch + + +async def _mk_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[StockAnalysis.__table__])) + return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +def _row(code, d, score=3, price=10000): + return StockAnalysis(stock_code=code, stock_name=f"N{code}", analysis_date=d, score=score, + rsi=40.0, current_price=price) + + +@pytest.mark.asyncio +async def test_populate_falls_back_to_latest_analysis_date(): + engine, Session = await _mk_session() + today = datetime.now(es._KST).date() + yday = today - timedelta(days=1) + async with Session() as s: + s.add_all([_row("000001", yday), _row("000002", yday, score=5), _row("000003", yday, price=3000)]) + await s.commit() + with patch("app.core.database.AsyncSessionLocal", Session): + n = await es.populate_daytrading_watchlist() + assert n == 2 # 5,000원 미만 제외 + assert set(es.get_watchlist()) == {"000001", "000002"} + assert es.is_watchlist_auto() is True + + # 당일 데이터가 생기면 당일 결과가 우선 + async with Session() as s: + s.add(_row("000009", today)); await s.commit() + with patch("app.core.database.AsyncSessionLocal", Session): + n = await es.populate_daytrading_watchlist() + assert n == 1 and set(es.get_watchlist()) == {"000009"} + + # 수동 설정 후에는 auto 플래그 해제 → 스케줄러 30분 갱신 대상 아님 + es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) + assert es.is_watchlist_auto() is False + await engine.dispose() + + +@pytest.mark.asyncio +async def test_entry_no_lunch_block_and_cutoff_from_settings(): + """점심시간(12:00)에도 VWAP/RSI_BB 스캔이 수행되고, 마감은 설정값(15:00) 기준.""" + from app.services.daytrading.position_manager import day_trading_state + es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) + captured = {} + + async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False): + captured["is_lunch_break"] = is_lunch_break + return None + + class _Adp: # 적응형: 데이트레이딩 허용 + fallback_used = False; daytrading_enabled = True; trading_mode = "normal" + + async def run_at(hh, mm): + fake_now = datetime.now(es._KST).replace(hour=hh, minute=mm, second=0) + class _DT(datetime): + @classmethod + def now(cls, tz=None): + return fake_now if tz else fake_now.replace(tzinfo=None) + with patch.object(es, "datetime", _DT), \ + patch.object(es.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \ + patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \ + patch.object(es, "is_daytrading_allowed_by_playbook", return_value=True), \ + patch.object(es, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \ + patch.object(es.korea_investment_api, "get_balance", AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000", "prvs_rcdl_excc_amt": "5000000"}]})), \ + patch.object(es, "_try_enter", fake_try_enter): + captured.clear() + return await es.scan_and_enter() + + day_trading_state.positions.clear() + await run_at(12, 0) + assert captured.get("is_lunch_break") is False, "점심시간 제한이 제거되어야 함" + + await run_at(14, 45) + assert captured.get("is_lunch_break") is False, "14:30~15:00 구간도 진입 스캔해야 함" + + await run_at(15, 5) + assert captured == {}, "15:00 이후에는 스캔하지 않아야 함" + + +def test_capital_split_settings(): + assert settings.DAY_TRADE_MAX_RATIO_DT == 0.50 + assert settings.SWING_MAX_CAPITAL_RATIO == 0.50 + assert settings.DAY_TRADE_MAX_POSITIONS * settings.DAY_TRADE_MAX_CAPITAL_RATIO >= 0.48 + assert (settings.DAY_TRADE_ENTRY_CUTOFF_HOUR, settings.DAY_TRADE_ENTRY_CUTOFF_MIN) == (15, 0) + from app.services.day_trading_strategy import calculate_day_trade_position_size + # 총자산 1,000만: 종목당 60만, DT 합산 500만 + assert calculate_day_trade_position_size(10_000_000, 5_000_000, 0) == 600_000 + assert calculate_day_trade_position_size(10_000_000, 5_000_000, 4_800_000) == 200_000