fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,33 @@ _watchlist: dict[str, str] = {}
|
||||
_watchlist_auto: bool = False
|
||||
|
||||
|
||||
def _clamp_day_trade_budget(
|
||||
budget: float,
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_day_trade_amount: float,
|
||||
) -> float:
|
||||
"""[H3] 데이트레이딩 주문 예산을 세 가지 한도로 재클램프.
|
||||
|
||||
calculate_day_trade_position_size()와 동일한 한도를 적용한다
|
||||
(종목당 DAY_TRADE_MAX_CAPITAL_RATIO / DT 전체 DAY_TRADE_MAX_RATIO_DT 잔여 / 보유 현금).
|
||||
confidence 배율처럼 클램프 이후 예산을 변형하는 로직 뒤에 반드시 호출해야 한다.
|
||||
calculate_day_trade_position_size() 자체는 변경하지 않는다(시그니처 보존).
|
||||
|
||||
Args:
|
||||
budget: 클램프 대상 예산 (원)
|
||||
total_assets: 총자산
|
||||
available_cash: 주문 가능 현금
|
||||
current_day_trade_amount: 현재 데이트레이딩 투입 금액
|
||||
|
||||
Returns:
|
||||
float: 세 한도를 모두 만족하는 예산 (0 이상)
|
||||
"""
|
||||
per_position_cap = total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
remaining_dt_cap = total_assets * settings.DAY_TRADE_MAX_RATIO_DT - current_day_trade_amount
|
||||
return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash))
|
||||
|
||||
|
||||
def set_watchlist(stocks: list[dict]) -> None:
|
||||
"""감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외).
|
||||
|
||||
@@ -123,35 +150,45 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
|
||||
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
|
||||
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
|
||||
# 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다.
|
||||
# 세션 수명은 아래 블록으로 최소화하고, 실패 시 warning 으로 운영에 노출한다.
|
||||
latest_date = None
|
||||
rows: list = []
|
||||
try:
|
||||
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 구성")
|
||||
).scalar_one_or_none()
|
||||
if latest_date is None:
|
||||
logger.info("[DayEntry] stock_analysis 데이터 없음")
|
||||
return 0
|
||||
|
||||
result = await session.execute(
|
||||
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
|
||||
.where(
|
||||
StockAnalysis.analysis_date == latest_date,
|
||||
StockAnalysis.score >= min_score,
|
||||
StockAnalysis.rsi.isnot(None),
|
||||
StockAnalysis.current_price > 5000,
|
||||
result = await session.execute(
|
||||
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
|
||||
.where(
|
||||
StockAnalysis.analysis_date == latest_date,
|
||||
StockAnalysis.score >= min_score,
|
||||
StockAnalysis.rsi.isnot(None),
|
||||
StockAnalysis.current_price > 5000,
|
||||
)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(max_stocks)
|
||||
)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(max_stocks)
|
||||
)
|
||||
rows = result.all()
|
||||
rows = result.all()
|
||||
except Exception as e:
|
||||
logger.warning(f"[DayEntry] watchlist 자동 구성용 분석 조회 실패(읽기 전용): {e}")
|
||||
return 0
|
||||
|
||||
if latest_date != today:
|
||||
logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성")
|
||||
|
||||
if not rows:
|
||||
logger.info(f"[DayEntry] stock_analysis({latest_date})에 조건 충족 종목 없음")
|
||||
@@ -209,6 +246,7 @@ async def scan_and_enter() -> list[dict]:
|
||||
|
||||
진입 전 체크 순서 (P0 = 최우선, P1 = 중요):
|
||||
[P0] MDD -10% 체크 → 단기 전략 비활성화
|
||||
[P0] MDD 공통 진입 게이트 can_open_new_position() (is_paused + 갱신 장애 fail-safe)
|
||||
[P0] 플레이북 체크 → FOMC/옵션만기 등 이벤트 당일 차단
|
||||
[P0] 일일 손실 한도 -3% 체크
|
||||
[P1] 3연패 쿨다운 체크
|
||||
@@ -222,12 +260,31 @@ async def scan_and_enter() -> list[dict]:
|
||||
day_trading_state.reset_if_new_day()
|
||||
entries = []
|
||||
|
||||
# MDD 체크
|
||||
# MDD 체크 ① — 단기/데이트레이딩 전략 비활성화 여부 (MDD -10% 시 pause)
|
||||
short_term_ok = await mdd_manager.is_short_term_strategy_enabled()
|
||||
if not short_term_ok:
|
||||
logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화")
|
||||
return entries
|
||||
|
||||
# MDD 체크 ② — 공통 신규 진입 게이트 (CLAUDE.md §5.3)
|
||||
# [항목7 2026-09-27] DT 진입 경로는 is_short_term_strategy_enabled() 만 봤기 때문에
|
||||
# can_open_new_position() 의 fail-safe(MDD 갱신 연속 실패 = peak/drawdown 정지로
|
||||
# -10%/-20% 게이트 무력화)와 is_paused 가 적용되지 않았다. 두 게이트를 모두
|
||||
# 통과해야 진입한다. 이 블록은 try 밖에 두어 예외로 우회되지 않게 하고,
|
||||
# 종목 루프/분봉 API 호출 전에 배치해 불필요한 KIS 호출을 없앤다.
|
||||
# 청산 경로(exit_service)에는 영향 없음.
|
||||
if not await mdd_manager.can_open_new_position():
|
||||
_health = mdd_manager.get_update_health()
|
||||
if _health.get("blind_block_active"):
|
||||
logger.error(
|
||||
"[DayEntry] ⛔ MDD 갱신 장애(fail-safe) — 데이트레이딩 신규 진입 차단 "
|
||||
f"(연속 실패 {_health.get('consecutive_failures')}회 / 임계 "
|
||||
f"{_health.get('blind_block_threshold')}, 마지막 오류: {_health.get('last_error')})"
|
||||
)
|
||||
else:
|
||||
logger.warning("[DayEntry] ⛔ MDD 임계 초과(is_paused) — 데이트레이딩 신규 진입 차단")
|
||||
return entries
|
||||
|
||||
# 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
@@ -433,8 +490,17 @@ async def _try_enter(
|
||||
current_day_trade_amount=current_day_trade_amount,
|
||||
)
|
||||
# confidence 스케일링: 기준 0.7 대비 비례 조정 (0.6→86%, 0.7→100%, 0.85→121%)
|
||||
# [H3 fix 2026-09-27] 예전 코드는 calculate_day_trade_position_size()가 이미
|
||||
# min(종목당 6%, DT 잔여 30%, 보유현금)로 클램프한 값에 최대 1.2배를 곱해
|
||||
# 세 한도를 모두 최대 20% 초과했다(ORB confidence 상한 0.85 → 실제로 1.2배 발동).
|
||||
# → 배율 적용 후 동일한 세 한도로 재클램프한다. 감액(<1.0)은 그대로 적용된다.
|
||||
confidence_scale = min(1.2, signal.confidence / 0.7)
|
||||
budget = budget * confidence_scale
|
||||
budget = _clamp_day_trade_budget(
|
||||
budget * confidence_scale,
|
||||
total_assets=total_assets,
|
||||
available_cash=available_cash,
|
||||
current_day_trade_amount=current_day_trade_amount,
|
||||
)
|
||||
quantity = int(budget // current_price) if current_price > 0 else 0
|
||||
if quantity <= 0:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user