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>
This commit is contained in:
2026-09-22 13:29:02 +09:00
parent 62c324e346
commit e0178aaf0d
6 changed files with 196 additions and 28 deletions

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