Compare commits

..

2 Commits

Author SHA1 Message Date
15018ddd72 Merge branch 'dev' into main — DT ORB 복구·50:50·시간대 완화 2026-09-22 13:29:04 +09:00
e0178aaf0d feat: 데이트레이딩 ORB 복구 + 스윙:DT 자본 50:50 + 시간대 제한 완화
- ORB: 09:00에 당일 분석이 없으면 최근 분석일 결과로 watchlist 선채움 → ORB 수집 가능.
  자동 목록은 30분마다 갱신(당일 분석 완료 시 교체), 수동 설정 목록은 덮어쓰지 않음.
- 자본 배분: DAY_TRADE_MAX_RATIO_DT 0.25→0.50, 종목당 4%→6%, 동시 3→8종목.
  SWING_MAX_CAPITAL_RATIO=0.50 신설 — 스윙 합산 투자액 초과 시 스윙 신규 매수 차단(trade_executor).
- 시간대: 점심(11:30~13:00) VWAP/RSI_BB 제한 제거, 하드코딩 14:30 마감 → 설정값 15:00.
- 회귀 테스트 3건, CLAUDE.md §6/§7.3/§8.2 갱신.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 13:29:02 +09:00
6 changed files with 196 additions and 28 deletions

View File

@@ -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 = 코스피200<MA200 & 기울기<0 / **SIDEWAYS = ADX<20 or 기울기≈0 → 모든 진입 무시** (market_analyzer: position_multiplier=0, max_trades=0)
- **진입** (RSI 임계값은 §8.3 config 참조): BULL: RSI(14)<46 + BB하단 부근(-3%~+1.5%) + 거래량>20MA / BEAR: RSI<35 + 가격<BB하단 + 거래량>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 감시목록 선채움)*

View File

@@ -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 분당평균)

View File

@@ -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
# 포지션 한도 체크

View File

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

View File

@@ -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% 범위에서 동적 조정.

View File

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