fix: ML 게이트 스케일 불일치 + 운영 실측 반영 (문서 정정 3건)

## ML 게이트 이중 블렌딩 (플래그 OFF 로 출고)
market_analyzer.py:603-608 이 이미 rule30/ML70 으로 블렌딩한 뒤 0~15 로 축소
저장하는데, trade_executor 가 그 값을 정규화 없이 **또** 블렌딩했다.
  weighted_score = score_val(0~15) * 0.3 + ml_score(0~100) * 0.7  ≥ 60
룰 기여가 최대 4.5 로 임계 60 의 7.5% — 문서상 "룰 30%" 가 지켜지지 않았다.

_ml_gate_score_v2() 신설 + ML_SCORE_NORMALIZE_ENABLED(기본 False).
OFF 시 7,373 조합 비트 동일 검증. RULE_SCORE_MAX=15 신설(하드코딩 제거).

운영 실측으로 확인된 것: ML-BUY 분기는 어느 설정으로도 발화 불가.
  OFF: ml_score ≥ 85.7 필요 / 실측 ml_score 최대 50
  ON : score ≥ 9 필요    / 실측 score 역대 최대 6 (801건)
즉 ML 게이트는 HOLD 차단(브레이크)으로만 기능해 왔다. 임계값 재보정이
필요하며 §8.1-5 에 따라 백테스트가 선행돼야 한다.

## 문서 정정 (잘못된 전제 제거)
1. "NAS 다운 → 백테스트 불가" 는 사실이 아니다. NAS 는 가동 중이고
   autostock-postgres 도 떠 있다. DB 가 127.0.0.1:15433 바인딩이라 외부
   직결만 안 되는 것이며, NAS 내부에서 접근 가능하다. **백테스트 재실행 가능.**
2. docs/BATCH_GUIDE.md 의 cron 서술 정정 — 운영 NAS 에 crontab 이 없고
   앱 내부 스케줄러(main.py:269 → schedulers.py:74, 10분 간격)가 배치를 돌린다.
   setup-cron.sh 를 쓰면 오히려 중복 실행된다.
3. market_schedule.py 에 휴장일 판정이 없다 — 2026-09-24~25 거래·분석 0건은
   추석 연휴이며 장애가 아니다.

## §8.4.1 운영 실측 신설
- daytrading 257건 vs swing 53건 (DT 가 거래 건수 주력). DT 포지션 0 은
  15:20 EOD 전량 청산이 설계대로 동작한 결과
- strategy_configs 가 옛 값으로 운영되던 상태(RSI 30 / SL -6%)를
  2026-09-28 배포(alembic 0011→0012)로 해소
- 룰 점수는 0~15 스케일(market_analyzer.py:608 에서 축소 저장), 실측 -1~6
- 9/23 에 active_trades 유령 행 129개(day_trade 120 + swing 9)를 수동 정리한
  백업이 남아 있다 — 전략명 없이 close 해 엉뚱한 행이 닫히던 문제의 증거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 11:27:06 +09:00
parent 1b9257c4d2
commit 2425fd2276
4 changed files with 80 additions and 8 deletions

View File

@@ -295,14 +295,26 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat
6. 몬테카를로(거래 순서 셔플 10,000회) — MDD 95th pctile, §5.3 트리거 빈도 변화
7. 미국은 수정 전/후 A-B 로 1봉 정렬 효과를 분리 측정한 뒤 walk-forward
### 8.4.1 운영 실측 (2026-09-28)
운영 DB 를 직접 조회한 결과. 배포 전/후를 구분해 기록한다.
- **거래 이력**: `daytrading` 257건(BUY 127/SELL 130, 2026-04-06~09-23) vs `swing` 53건(BUY 15/SELL 38). **DT 가 거래 건수로는 주력**이었다. DT 포지션이 항상 0인 것은 15:20 EOD 전량 청산이 설계대로 동작한 결과이며 결함이 아니다.
- **2026-09-24~25 거래·분석 0건은 추석 연휴**다. `market_schedule.py` 에 휴장일 판정이 없어 로그만으로는 장애와 구분되지 않는다.
- **`strategy_configs` 가 옛 값으로 운영되고 있었다** (alembic 0012 배포 전): BULL 진입 RSI **30**(config 46), 손절 **-6%**(-7%), DT 3종목·종목당 10%. RSI<46 후보는 95종목인데 RSI<30 은 **32종목** — 후보가 3분의 1 이었다. **2026-09-28 배포(alembic 0011→0012)로 해소됨.**
- **룰 점수는 0~15 스케일**이다(`market_analyzer.py:608` 이 `combined_score/100*15` 로 축소 저장). 실측 분포는 최소 -1 / 최대 6 / 평균 1.4 (199종목). 따라서 `score >= 60` 형태의 비교는 **구조적으로 충족 불가**다.
- **배치는 cron 이 아니라 앱 내부 스케줄러**가 돌린다(`main.py:269` → `schedulers.py:74`, 10분 간격). NAS 에 crontab 은 등록되어 있지 않다.
- **9/23 에 `active_trades` 유령 행 129개**(`day_trade` 120 + `swing` 9)를 수동 정리한 백업이 배포 디렉터리에 남아 있다(`active_trades_backup_20260923_1036.sql`). 전략명 없이 close 해 엉뚱한 행이 닫히던 문제의 증거이며, 이번 세션에서 수정했다.
### 8.5 미해결 항목 (알려진 문제, 임의로 "고쳤다"고 판단하지 말 것)
> 2026-09-27 작업으로 이전 목록 11건 + 추가 발견 결함 다수가 해소되었다. 아래는 **그 과정에서 새로 드러난 것과 의도적으로 남긴 것**이다.
> 전체 유닛 스위트는 `559 passed / 0 failed`(11.8초, 소켓 차단 하) 상태다.
**백테스트 (최우선)**
1. **기존 백테스트 산출물 전부 폐기 상태 — 재실행 필요.** §8.4 의 최소 요건 7개를 갖춘 새 산출물이 나오기 전까지 **어떤 파라미터도 "검증됐다"고 말할 수 없다.** NAS(DB) 복구가 선행 조건.
2. `RANGE_TRADE_ENABLED` / `MEAN_REVERSION_BEAR_V2_ENABLED` 는 **백테스트 통과 전 True 금지**. 각 파일 docstring 에 통과 조건이 기재되어 있다. BEAR v2 는 "반전캔들 없이 %B+거래량으로 통과"가 발화의 53% 이므로 그 버킷만 분리 집계해 PF < 1.0 이면 `_W_REVERSAL` 을 필수화하거나 `_MIN_SCORE` 를 0.75 로 올려야 한다.
1. **기존 백테스트 산출물 전부 폐기 상태 — 재실행 필요.** §8.4 의 최소 요건 7개를 갖춘 새 산출물이 나오기 전까지 **어떤 파라미터도 "검증됐다"고 말할 수 없다.**
✅ **2026-09-28 정정: 재실행이 가능하다.** 이전 판이 "NAS 다운으로 백테스트 불가"라고 적은 것은 오류였다 — NAS(`nas.shium.net`, DNS 정상)는 가동 중이고 `autostock-postgres` 도 떠 있다. DB 포트가 `127.0.0.1:15433` 로 바인딩돼 **외부에서 직접 붙을 수 없을 뿐**이며, NAS 안에서(`docker exec autostock-postgres psql`) 또는 SSH 터널로 접근한다.
2. `RANGE_TRADE_ENABLED` / `MEAN_REVERSION_BEAR_V2_ENABLED` 는 **백테스트 통과 전 True 금지**. 각 파일 docstring 에 통과 조건이 기재되어 있다. (DB 가 살아 있으므로 실제로 검증할 수 있다.) BEAR v2 는 "반전캔들 없이 %B+거래량으로 통과"가 발화의 53% 이므로 그 버킷만 분리 집계해 PF < 1.0 이면 `_W_REVERSAL` 을 필수화하거나 `_MIN_SCORE` 를 0.75 로 올려야 한다.
3. 박스권 전략이 **스케줄러에 배선되어 있지 않다.** 배선 위치는 `swing/scheduler.py` 의 MDD/`AUTO_TRADE_ENABLED` 게이트 직후(기존 step 7 은 추천 데이터 존재를 전제하므로 SIDEWAYS 에서 도달하지 못한다). `swing/exit_service.py` 에 `RANGE_TRADE` 전용 청산 분기도 없어 현재는 평균회귀 청산으로 처리된다 — 박스 상단 익절·박스 이탈 즉시 손절이 적용되지 않는다.
**미국 전략 (`US_TRADING_ENABLED=False` 라 당장 위험은 없으나 켜기 전 필수)**

View File

@@ -442,6 +442,16 @@ class Settings(BaseSettings):
ML_ENSEMBLE_ML_WEIGHT: float = 0.7 # ML 스코어 비중
ML_ENSEMBLE_BUY_THRESHOLD: int = 60 # 매수 최소 앙상블 스코어 (0~100)
ML_ENSEMBLE_CONFIDENCE_MIN: float = 0.5 # 최소 신뢰도
# 룰베이스 스코어의 최대값 (market_analyzer 의 signal 가점 합 상한).
# 0~RULE_SCORE_MAX → 0~100 정규화에 사용. 하드코딩 15.0 을 대체한다.
RULE_SCORE_MAX: int = 15
# ML 게이트 점수 스케일 정규화 (기본 False = 기존 계산 그대로).
# False: trade_executor 의 ML 게이트가 0~15 룰점수와 0~100 ML점수를 그대로
# 가중합 (룰 기여도가 문서상 30% 가 아니라 실질 7.5%, ML 이중 계산).
# True : StockAnalysis.score 가 이미 rule+ML 블렌딩된 0~RULE_SCORE_MAX 값이므로
# 재블렌딩 없이 0~100 으로 환산해 임계와 직접 비교.
# 진입 판정이 바뀌는 변경이므로 백테스트 검증 전 활성화 금지.
ML_SCORE_NORMALIZE_ENABLED: bool = False
# ═════════════════════════════════════════════════════════════════════════════
# API & 네트워크

View File

@@ -1787,6 +1787,39 @@ async def _delayed_sync_portfolio(delay_seconds: int = 3):
logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}")
def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float:
"""ML 매수/HOLD 게이트 비교용 점수 산출 (0~100 스케일 기준).
[배경 — 2026-09-28 스케일 불일치 수정]
market_analyzer.py(:603-608) 는 룰 점수를 0~100 으로 정규화한 뒤
`rule*RULE_WEIGHT + ml*ML_WEIGHT` 로 블렌딩하고, 그 결과를 다시 0~15 로
축소해 StockAnalysis.score 에 저장한다. 즉 trade_executor 가 받는
stock["score"] 는 **이미 ML 이 반영된 0~RULE_SCORE_MAX 스케일** 값이다.
(ml_score 키가 실린 모든 추천 경로 = recommendations.py:45 _stock_analysis_to_dict /
recommendations.py:589 rec.model_dump() 가 동일하게 이 값을 전달한다.)
그런데 기존 게이트는 그 0~15 값을 정규화 없이 0~100 ML 점수와 다시 가중합해
① 룰 기여 상한이 15×0.3=4.5 (임계 60 의 7.5%) 로 사실상 소멸
② ml_score 를 이중 계산
하는 두 결함을 안고 있었다.
ML_SCORE_NORMALIZE_ENABLED=False(기본) → 기존 계산식을 비트 단위로 그대로 유지.
True → 재블렌딩을 제거하고 0~RULE_SCORE_MAX 를 0~100 으로 환산해 임계와 직접 비교.
(ML 은 이미 score 안에 ML_WEIGHT 비중으로 반영되어 있다.)
임계값·가중치(ML_ENSEMBLE_BUY_THRESHOLD/RULE_WEIGHT/ML_WEIGHT)는 무변경.
"""
if not settings.ML_SCORE_NORMALIZE_ENABLED:
# 기존 동작 (0~15 룰점수 + 0~100 ML점수 가중합) — 비트 동일
return (
rule_score * settings.ML_ENSEMBLE_RULE_WEIGHT
+ ml_score * settings.ML_ENSEMBLE_ML_WEIGHT
)
rule_max = float(settings.RULE_SCORE_MAX or 15)
if rule_max <= 0:
rule_max = 15.0
return (rule_score / rule_max) * 100.0
async def execute_auto_trade(stock: dict, db: AsyncSession):
"""종목 단건 매매 판단 및 실행.
@@ -1993,10 +2026,10 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None:
try:
weighted_score = (
score_val * settings.ML_ENSEMBLE_RULE_WEIGHT
+ float(ml_score_trade) * settings.ML_ENSEMBLE_ML_WEIGHT
)
# [2026-09-28] 스케일 불일치(0~15 룰점수 vs 0~100 임계) 및 ML 이중
# 계산 수정. ML_SCORE_NORMALIZE_ENABLED=False 면 기존 계산과 동일.
# HOLD 게이트(*0.7)도 같은 weighted_score 를 쓰므로 스케일 일관.
weighted_score = _ml_gate_score_v2(score_val, float(ml_score_trade))
if (
ml_signal_trade == "BUY"
and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD

View File

@@ -13,7 +13,19 @@
## 무엇이 언제 도는가
`setup-cron.sh` 가 등록하는 cron 잡은 **3개**다(모두 평일, `docker exec autostock-backend` 경유).
> ⚠️ **2026-09-28 정정**: 아래 cron 방식은 `setup-cron.sh` 가 제공하는 **선택적** 경로다.
> **운영 NAS 에는 crontab 이 등록되어 있지 않고**(root·사용자 모두 비어 있음), 실제로는
> **앱 내부 스케줄러가 배치를 돌린다.** 이전 판(2026-09-27)이 cron 을 주 메커니즘으로
> 기술한 것은 스크립트만 읽고 실제 운영 상태를 확인하지 않은 오류였다.
**실제 동작 (기본)**: 앱 기동 시 `main.py:269` 가 `batch_analysis_scheduler()` 태스크를 띄우고,
`services/schedulers.py:74` 가 **10분 간격**으로 `stock_analysis` 를 갱신한다. cron 등록 없이도
분석 데이터가 쌓이며, 실측으로 평일 200종목 전후가 매일 저장된다.
로그는 컨테이너 로그로 나간다: `docker logs autostock-backend | grep -i batch`
**선택: cron 등록** — `setup-cron.sh` 를 실행하면 아래 3개 잡이 등록된다(모두 평일, `docker exec` 경유).
앱 내부 스케줄러와 **중복 실행**되므로, 쓸 거라면 어느 쪽을 정본으로 할지 먼저 정하라.
| 시각 | 스크립트 | 로그 |
|------|----------|------|
@@ -21,7 +33,12 @@
| 08:50 | `batch_daily_analysis.py` | `logs/batch_analysis.log` |
| 09:00~15:00 매 10분 | `batch_intraday_analysis.py` | `logs/batch_intraday.log` |
토큰 프리페치가 06:00에 먼저 도는 이유: KIS 토큰 발급은 1분에 1회 제한이라, 08:50 배치가 토큰 발급부터 시작하면 실패 시 재시도 여유가 없다.
토큰 프리페치가 06:00 에 먼저 도는 이유: KIS 토큰 발급은 1분에 1회 제한이라, 08:50 배치가
토큰 발급부터 시작하면 실패 시 재시도 여유가 없다.
⚠️ **공휴일 인식이 없다.** `core/market_schedule.py` 는 요일·시간만 판정하고 휴장일 목록이 없다.
2026-09-24~25(추석 연휴)에 `stock_analysis` 행과 거래가 0건인 것은 **정상**이며 장애가 아니다.
휴장일에 분석이 도는 것을 막고 싶으면 휴장일 판정을 추가해야 한다.
## 수동 실행