release: ML 앙상블 전면 미작동 수정 (시계열 역방향 + 시퀀스 NaN) + 운영 파라미터 문서 정합
dev → main 릴리스. 알렘빅 변경 없음(스키마 마이그레이션 불필요). - fix(ml)43b4b1a: ML 앙상블 3중 결함 수정 + 회귀 테스트 20건 - feat(analysis)418371e: 손익 Top5 종목명 표시 - docs 2425fd2/3fdc19c: ML 게이트 스케일·§8.5 후속 항목 7건 ⚠️ 배포 후 관찰: trade_executor 의 ML HOLD 게이트가 되살아나므로 매수 건수가 줄 수 있다. 운영 LSTM 은 seq_std≈1e-8 로 출력이 난수이므로 재학습 전까지 ML 신뢰 금지 (CLAUDE.md §8.5-20~22). 유닛 스위트 579 passed / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
27
CLAUDE.md
27
CLAUDE.md
@@ -295,14 +295,26 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat
|
|||||||
6. 몬테카를로(거래 순서 셔플 10,000회) — MDD 95th pctile, §5.3 트리거 빈도 변화
|
6. 몬테카를로(거래 순서 셔플 10,000회) — MDD 95th pctile, §5.3 트리거 빈도 변화
|
||||||
7. 미국은 수정 전/후 A-B 로 1봉 정렬 효과를 분리 측정한 뒤 walk-forward
|
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 미해결 항목 (알려진 문제, 임의로 "고쳤다"고 판단하지 말 것)
|
### 8.5 미해결 항목 (알려진 문제, 임의로 "고쳤다"고 판단하지 말 것)
|
||||||
|
|
||||||
> 2026-09-27 작업으로 이전 목록 11건 + 추가 발견 결함 다수가 해소되었다. 아래는 **그 과정에서 새로 드러난 것과 의도적으로 남긴 것**이다.
|
> 2026-09-27 작업으로 이전 목록 11건 + 추가 발견 결함 다수가 해소되었다. 아래는 **그 과정에서 새로 드러난 것과 의도적으로 남긴 것**이다.
|
||||||
> 전체 유닛 스위트는 `559 passed / 0 failed`(11.8초, 소켓 차단 하) 상태다.
|
> 전체 유닛 스위트는 `559 passed / 0 failed`(11.8초, 소켓 차단 하) 상태다.
|
||||||
|
|
||||||
**백테스트 (최우선)**
|
**백테스트 (최우선)**
|
||||||
1. **기존 백테스트 산출물 전부 폐기 상태 — 재실행 필요.** §8.4 의 최소 요건 7개를 갖춘 새 산출물이 나오기 전까지 **어떤 파라미터도 "검증됐다"고 말할 수 없다.** NAS(DB) 복구가 선행 조건.
|
1. **기존 백테스트 산출물 전부 폐기 상태 — 재실행 필요.** §8.4 의 최소 요건 7개를 갖춘 새 산출물이 나오기 전까지 **어떤 파라미터도 "검증됐다"고 말할 수 없다.**
|
||||||
2. `RANGE_TRADE_ENABLED` / `MEAN_REVERSION_BEAR_V2_ENABLED` 는 **백테스트 통과 전 True 금지**. 각 파일 docstring 에 통과 조건이 기재되어 있다. BEAR v2 는 "반전캔들 없이 %B+거래량으로 통과"가 발화의 53% 이므로 그 버킷만 분리 집계해 PF < 1.0 이면 `_W_REVERSAL` 을 필수화하거나 `_MIN_SCORE` 를 0.75 로 올려야 한다.
|
✅ **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` 전용 청산 분기도 없어 현재는 평균회귀 청산으로 처리된다 — 박스 상단 익절·박스 이탈 즉시 손절이 적용되지 않는다.
|
3. 박스권 전략이 **스케줄러에 배선되어 있지 않다.** 배선 위치는 `swing/scheduler.py` 의 MDD/`AUTO_TRADE_ENABLED` 게이트 직후(기존 step 7 은 추천 데이터 존재를 전제하므로 SIDEWAYS 에서 도달하지 못한다). `swing/exit_service.py` 에 `RANGE_TRADE` 전용 청산 분기도 없어 현재는 평균회귀 청산으로 처리된다 — 박스 상단 익절·박스 이탈 즉시 손절이 적용되지 않는다.
|
||||||
|
|
||||||
**미국 전략 (`US_TRADING_ENABLED=False` 라 당장 위험은 없으나 켜기 전 필수)**
|
**미국 전략 (`US_TRADING_ENABLED=False` 라 당장 위험은 없으나 켜기 전 필수)**
|
||||||
@@ -329,4 +341,15 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat
|
|||||||
**멀티테넌트**
|
**멀티테넌트**
|
||||||
19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0013**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.**
|
19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0013**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.**
|
||||||
|
|
||||||
|
**ML 앙상블 (2026-09-28 — 코드는 고쳤으나 모델 재학습 전까지 신뢰 금지)**
|
||||||
|
20. **운영 LSTM 체크포인트가 무의미하다.** `seq_mean=[0,0,0,0,0]`, `seq_std=[1e-8]×5` — `X_seq` 가 전부 0 인 상태로 학습된 흔적이다(구 `prepare_training_data` 가 시퀀스에 NaN 을 넣어 `X_seq.mean` 이 NaN 이 된 결과). std≈0 이면 정규화가 `x/1e-8` 로 폭주해 **가중치 35% 를 가진 LSTM 출력이 사실상 난수**다. 시계열 방향 수정 + 시퀀스 NaN 수정이 들어갔으므로 **재학습이 필요**하다. 재학습 후 새 `metrics.json` 의 `lstm_seq_std_min` 과 `lstm_per_class` 로 판정할 것 — 정확도만 보면 "전부 HOLD" 모델을 통과시킨다.
|
||||||
|
21. **`ML_ENSEMBLE_BUY_THRESHOLD`(60) 재보정 보류 중.** 관측 `ensemble_score` 18~50 이라 ML BUY 가 구조적으로 0건이었다. 그러나 그 분포는 **역방향 시계열에서 나온 값**이므로 지금 임계값을 조정하면 잘못된 분포에 맞추는 것이 된다. 순서는 ① 재학습 → ② 분포 재측정 → ③ 임계값 판단이고, 값 변경은 §8.1-5(백테스트)를 거친다.
|
||||||
|
22. ⚠️ **`trade_executor` 의 ML HOLD 게이트가 다시 작동한다.** 지난 닷새간 `ml_signal=NULL` 이라 무력화돼 있었다(§6.4). 이번 수정으로 HOLD & 신뢰도≥60% 조건이 되살아나므로 **배포 후 매수 건수가 줄 수 있다.** 20번(LSTM 무의미)이 해소되기 전에는 이 게이트가 난수로 매수를 막을 수 있다 — 매수 0건이 재발하면 `ml_signal` 분포를 먼저 확인할 것.
|
||||||
|
23. **`load_models()` 는 all-or-nothing.** lgbm 로드 실패 한 번으로 scaler/rf/lstm 까지 버리고 `is_trained=False` 가 된다(로컬에서 libomp 부재로 실제 발생). `logger.error` 는 남는다. 부분 로드 허용은 정책 결정이라 손대지 않았다.
|
||||||
|
24. **KIS 폴백 경로의 실제 봉수가 미확인이다.** `get_daily_chart(limit=240)` 은 페이지네이션(tr_cont F/M)에 의존하므로 연속조회가 실패하면 80봉 미달로 ML 이 계속 스킵된다. 운영 로그 `✅ Fetched N chart data points` 의 N 을 확인할 것. 네이버 경로는 154봉으로 충족(실측).
|
||||||
|
25. **하드코딩 3건이 남아 있다** — `market_analyzer._NAVER_ML_CHART_DAYS=200`, `_NAVER_LEGACY_DAYS=80`, `_ML_SKIP_LOG_EVERY=50`. config 화가 필요하면 `ML_CHART_DAYS`/`ML_SKIP_LOG_EVERY` 로 편입. `RECOMMENDED_BARS_FOR_PREDICT=120` 은 현재 **강제하지 않는다**(최소 80만 강제) — RSI(Wilder)·EMA 가 재귀라 80~120봉 구간의 학습-추론 정합성은 재학습 후 A-B 로 확인할 것.
|
||||||
|
26. 종가 0/결측이 중간에 섞인 차트는 피처가 유한해 **여전히 점수가 산출된다**(NaN 문제가 아니라 데이터 품질 문제). 연속 0봉 비율 등 품질 검증은 별도 작업.
|
||||||
|
|
||||||
*마지막 업데이트: 2026-09-27 — 죽은 코드 2,695줄 제거 / 지표·사이징 단일화(수치 무변경 증명) / 파라미터 3중 소스 정합 + alembic 0012 / 섹터 매핑 44→747종목 / 백테스트를 실전 로직에 정합(ADX 14배 발산·RSI SMA↔Wilder·1봉 지연) / 미국 상수 13개 config 편입 / 테스트 실 KIS 호출 차단(conftest 신설) / 유닛 스위트 559 passed·0 failed*
|
*마지막 업데이트: 2026-09-27 — 죽은 코드 2,695줄 제거 / 지표·사이징 단일화(수치 무변경 증명) / 파라미터 3중 소스 정합 + alembic 0012 / 섹터 매핑 44→747종목 / 백테스트를 실전 로직에 정합(ADX 14배 발산·RSI SMA↔Wilder·1봉 지연) / 미국 상수 13개 config 편입 / 테스트 실 KIS 호출 차단(conftest 신설) / 유닛 스위트 559 passed·0 failed*
|
||||||
|
|
||||||
|
*2026-09-28 — 운영 파라미터 alembic 0012 배포 완료(RSI 30→46, 손절 -6%→-7%) / ML 앙상블 전면 미작동 수정(시계열 역방향·시퀀스 워밍업 NaN·`except Exception: pass`) + 회귀 테스트 20건 / 유닛 스위트 579 passed·0 failed. **ML 은 재학습 전까지 신뢰 금지(§8.5-20).***
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import logging
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from app.services.ml_ensemble import get_ensemble
|
from app.services.ml_ensemble import get_ensemble
|
||||||
|
from app.services.feature_engineering import MIN_BARS_FOR_PREDICT
|
||||||
from app.services.korea_investment_api import korea_investment_api
|
from app.services.korea_investment_api import korea_investment_api
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
@@ -48,12 +49,22 @@ async def predict_stock(stock_code: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
||||||
chart_data = chart_resp.get("output", [])
|
# get_daily_chart 는 페이지네이션 결과를 output2 에 담는다.
|
||||||
|
# (과거 이 핸들러는 "output" 을 읽어 항상 빈 리스트 → 400 이었다. 2026-09-28)
|
||||||
|
chart_data = chart_resp.get("output2") or chart_resp.get("output") or []
|
||||||
|
|
||||||
if not chart_data or len(chart_data) < 61:
|
if len(chart_data) < MIN_BARS_FOR_PREDICT:
|
||||||
raise HTTPException(status_code=400, detail=f"차트 데이터 부족: {len(chart_data) if chart_data else 0}일 (최소 61일 필요)")
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"차트 데이터 부족: {len(chart_data)}봉 "
|
||||||
|
f"(최소 {MIN_BARS_FOR_PREDICT}봉 필요 — "
|
||||||
|
f"LSTM 시퀀스 + 지표 워밍업)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
result = ensemble.predict(chart_data)
|
# async 핸들러에서 동기 predict() 를 호출하면 이벤트 루프를 막는다.
|
||||||
|
result = await ensemble.predict_async(chart_data)
|
||||||
result["stock_code"] = stock_code
|
result["stock_code"] = stock_code
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -442,6 +442,16 @@ class Settings(BaseSettings):
|
|||||||
ML_ENSEMBLE_ML_WEIGHT: float = 0.7 # ML 스코어 비중
|
ML_ENSEMBLE_ML_WEIGHT: float = 0.7 # ML 스코어 비중
|
||||||
ML_ENSEMBLE_BUY_THRESHOLD: int = 60 # 매수 최소 앙상블 스코어 (0~100)
|
ML_ENSEMBLE_BUY_THRESHOLD: int = 60 # 매수 최소 앙상블 스코어 (0~100)
|
||||||
ML_ENSEMBLE_CONFIDENCE_MIN: float = 0.5 # 최소 신뢰도
|
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 & 네트워크
|
# API & 네트워크
|
||||||
|
|||||||
@@ -41,6 +41,33 @@ def chart_data_to_dataframe(chart_data: list) -> pd.DataFrame:
|
|||||||
return df
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def chart_data_to_dataframe_v2(chart_data: list) -> pd.DataFrame:
|
||||||
|
"""KIS 형식 차트 데이터를 **입력 순서와 무관하게** 시간순 DataFrame으로 변환.
|
||||||
|
|
||||||
|
chart_data_to_dataframe() 은 "입력이 최신순"이라는 가정 아래 무조건 뒤집는다.
|
||||||
|
그런데 market_analyzer 는 oldest-first 로 정규화한 배열을 넘기므로
|
||||||
|
(analyze_stock: `list(reversed(kis_output2))`, naver: oldest-first)
|
||||||
|
그 함수를 그대로 쓰면 **시계열이 역방향으로 뒤집힌 상태**로 피처가 계산된다.
|
||||||
|
학습 데이터(db_data_to_dataframe → trade_date ASC)는 정방향이므로
|
||||||
|
추론이 학습과 정반대 방향의 시계열을 보게 되는 결함이었다. (2026-09-28)
|
||||||
|
|
||||||
|
stck_bsop_date 를 기준으로 오름차순 정렬하여 방향 의존성을 제거한다.
|
||||||
|
날짜가 없으면 기존 동작(최신순 가정 → reverse)으로 폴백한다.
|
||||||
|
"""
|
||||||
|
if not chart_data:
|
||||||
|
return chart_data_to_dataframe(chart_data)
|
||||||
|
|
||||||
|
dates = [str(item.get("stck_bsop_date", "") or "") for item in chart_data]
|
||||||
|
if not all(len(d) == 8 and d.isdigit() for d in dates):
|
||||||
|
# 날짜 누락/형식 이상 → 기존 규약(최신순 입력) 유지
|
||||||
|
return chart_data_to_dataframe(chart_data)
|
||||||
|
|
||||||
|
order = sorted(range(len(chart_data)), key=lambda i: dates[i])
|
||||||
|
ordered = [chart_data[i] for i in order]
|
||||||
|
# chart_data_to_dataframe 은 뒤집으므로, 정렬 결과를 한 번 더 뒤집어 넘긴다
|
||||||
|
return chart_data_to_dataframe(list(reversed(ordered)))
|
||||||
|
|
||||||
|
|
||||||
def db_data_to_dataframe(rows: list) -> pd.DataFrame:
|
def db_data_to_dataframe(rows: list) -> pd.DataFrame:
|
||||||
"""DB StockDailyPrice 레코드를 DataFrame(시간순)으로 변환.
|
"""DB StockDailyPrice 레코드를 DataFrame(시간순)으로 변환.
|
||||||
|
|
||||||
@@ -271,6 +298,26 @@ FEATURE_COLUMNS = [
|
|||||||
# LSTM 시퀀스에 사용할 피처 (5개)
|
# LSTM 시퀀스에 사용할 피처 (5개)
|
||||||
LSTM_SEQUENCE_FEATURES = ["close", "volume", "rsi_14", "ma20_ratio", "volatility_20d"]
|
LSTM_SEQUENCE_FEATURES = ["close", "volume", "rsi_14", "ma20_ratio", "volatility_20d"]
|
||||||
|
|
||||||
|
# LSTM 시퀀스 길이
|
||||||
|
LSTM_SEQUENCE_LENGTH = 60
|
||||||
|
|
||||||
|
# ─── 워밍업 상수 (extract_features() 구현과 1:1 대응, 실측값) ───
|
||||||
|
# 각 피처가 처음으로 non-NaN 이 되는 인덱스:
|
||||||
|
# rsi_14=14, macd=25, macd_signal/hist=33, ma60_ratio=59,
|
||||||
|
# ma20_ratio/bb_position/volume_ratio/disparity_20=19, volatility_20d=20
|
||||||
|
# → 탭뷸러 마지막 행이 전부 유효하려면 인덱스 59 이상 (봉 60개)
|
||||||
|
TABULAR_WARMUP_BARS = 60
|
||||||
|
# → LSTM 시퀀스(마지막 60행)가 전부 유효하려면 시퀀스 시작 인덱스 >= 20
|
||||||
|
# (volatility_20d 의 첫 유효 인덱스)
|
||||||
|
SEQUENCE_FEATURE_WARMUP_BARS = 20
|
||||||
|
|
||||||
|
# predict() 에 필요한 최소 봉수 = 시퀀스 60 + 시퀀스 피처 워밍업 20 = 80
|
||||||
|
# (61봉이면 탭뷸러는 통과하지만 시퀀스 앞쪽 20행이 NaN → 모델이 NaN 을 전파해
|
||||||
|
# `int(prob_up*100)` 에서 ValueError. 2026-09-23~28 ML 전면 미작동 원인)
|
||||||
|
MIN_BARS_FOR_PREDICT = LSTM_SEQUENCE_LENGTH + SEQUENCE_FEATURE_WARMUP_BARS
|
||||||
|
# 권장 봉수: RSI(Wilder)·EMA 등 재귀 지표의 번인을 감안한 여유치
|
||||||
|
RECOMMENDED_BARS_FOR_PREDICT = 120
|
||||||
|
|
||||||
|
|
||||||
def prepare_training_data(
|
def prepare_training_data(
|
||||||
df: pd.DataFrame,
|
df: pd.DataFrame,
|
||||||
@@ -314,17 +361,6 @@ def prepare_training_data(
|
|||||||
if valid_mask[i] and np.any(np.isnan(tabular[i])):
|
if valid_mask[i] and np.any(np.isnan(tabular[i])):
|
||||||
valid_mask[i] = False
|
valid_mask[i] = False
|
||||||
|
|
||||||
valid_indices = np.where(valid_mask)[0]
|
|
||||||
|
|
||||||
if len(valid_indices) == 0:
|
|
||||||
return np.array([]), np.array([]), np.array([]), np.array([])
|
|
||||||
|
|
||||||
# 탭뷸러 피처
|
|
||||||
X_tabular = tabular[valid_indices]
|
|
||||||
|
|
||||||
# 레이블
|
|
||||||
y = labels[valid_indices].astype(int)
|
|
||||||
|
|
||||||
# LSTM 시퀀스 피처 준비
|
# LSTM 시퀀스 피처 준비
|
||||||
# 시퀀스에 사용할 raw 피처 결합
|
# 시퀀스에 사용할 raw 피처 결합
|
||||||
seq_cols = []
|
seq_cols = []
|
||||||
@@ -337,6 +373,31 @@ def prepare_training_data(
|
|||||||
seq_cols.append(np.zeros(n, dtype=float))
|
seq_cols.append(np.zeros(n, dtype=float))
|
||||||
seq_raw = np.column_stack(seq_cols) # (n, 5)
|
seq_raw = np.column_stack(seq_cols) # (n, 5)
|
||||||
|
|
||||||
|
# [2026-09-28] 시퀀스 윈도우 NaN 체크 — 기존에는 `valid_mask[:60]=False` 만 있어
|
||||||
|
# 인덱스 60~79 샘플의 시퀀스(seq_raw[idx-60:idx])에 워밍업 NaN 이 섞여 들어갔다.
|
||||||
|
# X_seq 에 NaN 이 하나라도 있으면 `X_seq.mean(axis=(0,1))` = NaN 이 되어
|
||||||
|
# seq_mean/seq_std 가 NaN → LSTM 이 학습·추론 전체에서 NaN 을 뱉는다.
|
||||||
|
seq_row_finite = np.all(np.isfinite(seq_raw), axis=1)
|
||||||
|
# 누적합으로 각 윈도우의 유효 행 수를 O(n) 에 계산
|
||||||
|
finite_cumsum = np.concatenate(([0], np.cumsum(seq_row_finite.astype(int))))
|
||||||
|
for i in range(n):
|
||||||
|
if not valid_mask[i]:
|
||||||
|
continue
|
||||||
|
start = i - seq_window
|
||||||
|
if start < 0 or (finite_cumsum[i] - finite_cumsum[start]) < seq_window:
|
||||||
|
valid_mask[i] = False
|
||||||
|
|
||||||
|
valid_indices = np.where(valid_mask)[0]
|
||||||
|
|
||||||
|
if len(valid_indices) == 0:
|
||||||
|
return np.array([]), np.array([]), np.array([]), np.array([])
|
||||||
|
|
||||||
|
# 탭뷸러 피처
|
||||||
|
X_tabular = tabular[valid_indices]
|
||||||
|
|
||||||
|
# 레이블
|
||||||
|
y = labels[valid_indices].astype(int)
|
||||||
|
|
||||||
X_seq = np.zeros((len(valid_indices), seq_window, len(LSTM_SEQUENCE_FEATURES)), dtype=float)
|
X_seq = np.zeros((len(valid_indices), seq_window, len(LSTM_SEQUENCE_FEATURES)), dtype=float)
|
||||||
for j, idx in enumerate(valid_indices):
|
for j, idx in enumerate(valid_indices):
|
||||||
X_seq[j] = seq_raw[idx - seq_window:idx]
|
X_seq[j] = seq_raw[idx - seq_window:idx]
|
||||||
@@ -350,17 +411,54 @@ def extract_single_features(chart_data: list) -> Tuple[Optional[np.ndarray], Opt
|
|||||||
Returns:
|
Returns:
|
||||||
tabular_features: (20,) 탭뷸러 피처 또는 None
|
tabular_features: (20,) 탭뷸러 피처 또는 None
|
||||||
sequence_features: (60, 5) LSTM 시퀀스 또는 None
|
sequence_features: (60, 5) LSTM 시퀀스 또는 None
|
||||||
"""
|
|
||||||
if not chart_data or len(chart_data) < 61:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
df = chart_data_to_dataframe(chart_data)
|
실패 사유가 필요하면 extract_single_features_v2() 를 사용하세요.
|
||||||
|
"""
|
||||||
|
tabular, sequence, _reason = extract_single_features_v2(chart_data)
|
||||||
|
return tabular, sequence
|
||||||
|
|
||||||
|
|
||||||
|
def extract_single_features_v2(
|
||||||
|
chart_data: list,
|
||||||
|
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], Optional[str]]:
|
||||||
|
"""extract_single_features() + 실패 사유 반환 (2026-09-28 신설).
|
||||||
|
|
||||||
|
기존 버전의 결함 두 가지를 여기서 바로잡는다.
|
||||||
|
|
||||||
|
1) **최소 봉수 61 → 80**.
|
||||||
|
61봉이면 탭뷸러 마지막 행은 유효하지만(워밍업 60) LSTM 시퀴스
|
||||||
|
`seq_raw[-60:]` 의 앞쪽 행에 NaN 이 남는다
|
||||||
|
(volatility_20d 첫 유효 인덱스 20, ma20_ratio 19, rsi_14 14).
|
||||||
|
그 NaN 은 예외를 던지지 않고 LSTM → 가중평균 확률로 **전파**되어
|
||||||
|
`int(prob_up * 100)` 에서 `ValueError: cannot convert float NaN to integer`
|
||||||
|
를 유발했다. 봉수를 워밍업에 맞춰 올리는 것이 근본 수정이다.
|
||||||
|
|
||||||
|
2) **입력 순서 의존 제거**. chart_data_to_dataframe() 은 입력이 최신순이라
|
||||||
|
가정해 무조건 뒤집는데, 호출부(market_analyzer)는 oldest-first 를 넘긴다.
|
||||||
|
→ 시계열이 역방향으로 뒤집힌 상태로 피처가 계산되고 있었다.
|
||||||
|
chart_data_to_dataframe_v2() 로 날짜 기준 정렬한다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(tabular(20,), sequence(60,5), None) 또는 (None, None, 실패사유)
|
||||||
|
"""
|
||||||
|
if not chart_data:
|
||||||
|
return None, None, "차트 데이터 없음"
|
||||||
|
if len(chart_data) < MIN_BARS_FOR_PREDICT:
|
||||||
|
return None, None, (
|
||||||
|
f"봉수 부족 {len(chart_data)}/{MIN_BARS_FOR_PREDICT}"
|
||||||
|
f"(시퀀스{LSTM_SEQUENCE_LENGTH}+워밍업{SEQUENCE_FEATURE_WARMUP_BARS})"
|
||||||
|
)
|
||||||
|
|
||||||
|
df = chart_data_to_dataframe_v2(chart_data)
|
||||||
|
if len(df) < MIN_BARS_FOR_PREDICT:
|
||||||
|
return None, None, f"변환 후 봉수 부족 {len(df)}/{MIN_BARS_FOR_PREDICT}"
|
||||||
features_df = extract_features(df)
|
features_df = extract_features(df)
|
||||||
|
|
||||||
# 마지막 행의 탭뷸러 피처
|
# 마지막 행의 탭뷸러 피처
|
||||||
last_row = features_df[FEATURE_COLUMNS].iloc[-1].values
|
last_row = features_df[FEATURE_COLUMNS].iloc[-1].values.astype(float)
|
||||||
if np.any(np.isnan(last_row)):
|
if not np.all(np.isfinite(last_row)):
|
||||||
return None, None
|
bad = [c for c, v in zip(FEATURE_COLUMNS, last_row) if not np.isfinite(v)]
|
||||||
|
return None, None, f"탭뷸러 피처 무효: {','.join(bad)}"
|
||||||
|
|
||||||
# LSTM 시퀀스 (최근 60일)
|
# LSTM 시퀀스 (최근 60일)
|
||||||
seq_cols = []
|
seq_cols = []
|
||||||
@@ -373,9 +471,16 @@ def extract_single_features(chart_data: list) -> Tuple[Optional[np.ndarray], Opt
|
|||||||
seq_cols.append(np.zeros(len(df), dtype=float))
|
seq_cols.append(np.zeros(len(df), dtype=float))
|
||||||
seq_raw = np.column_stack(seq_cols)
|
seq_raw = np.column_stack(seq_cols)
|
||||||
|
|
||||||
if len(seq_raw) < 60:
|
if len(seq_raw) < LSTM_SEQUENCE_LENGTH:
|
||||||
return None, None
|
return None, None, f"시퀀스 길이 부족 {len(seq_raw)}/{LSTM_SEQUENCE_LENGTH}"
|
||||||
|
|
||||||
sequence = seq_raw[-60:] # (60, 5)
|
sequence = seq_raw[-LSTM_SEQUENCE_LENGTH:].astype(float) # (60, 5)
|
||||||
|
|
||||||
return last_row, sequence
|
# 최소 봉수를 만족해도 데이터에 결측/0가격이 섞이면 NaN 이 남을 수 있다.
|
||||||
|
# 여기서 반드시 잡아야 모델이 NaN 을 전파하지 않는다.
|
||||||
|
if not np.all(np.isfinite(sequence)):
|
||||||
|
bad_idx = np.where(~np.isfinite(sequence).all(axis=0))[0]
|
||||||
|
bad = [LSTM_SEQUENCE_FEATURES[i] for i in bad_idx]
|
||||||
|
return None, None, f"시퀀스 피처 무효: {','.join(bad)}"
|
||||||
|
|
||||||
|
return last_row, sequence, None
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import pytz
|
import pytz
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -429,13 +430,144 @@ def _calculate_technical_indicators(chart_data: List[dict], closes: List[float])
|
|||||||
return indicators
|
return indicators
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 네이버 일봉 봉수 (2026-09-28) ─────────────────────────────────────────
|
||||||
|
# get_naver_daily_chart(days=N) 은 [오늘-(N+30)일, 오늘] 캘린더 창을 요청한다.
|
||||||
|
# days=80 → 110일 창 → 실측 75~77 거래일 (추석 연휴 포함 시 75)
|
||||||
|
# days=200 → 230일 창 → 약 155 거래일
|
||||||
|
# ML 최소 요구치는 80봉(시퀀스 60 + 시퀀스 피처 워밍업 20)이므로 기존 기본값으로는
|
||||||
|
# 항상 미달이었다. ML 입력만 확장하고 룰 입력은 기존 창으로 자른다.
|
||||||
|
_NAVER_ML_CHART_DAYS = 200
|
||||||
|
_NAVER_LEGACY_DAYS = 80 # 기존 동작(=get_naver_daily_chart 기본값) 재현용
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_to_legacy_window(
|
||||||
|
chart_data: Optional[List[dict]], legacy_days: int,
|
||||||
|
) -> Optional[List[dict]]:
|
||||||
|
"""확장 조회 결과를 기존 창(days=legacy_days)과 **동일한 배열**로 자른다.
|
||||||
|
|
||||||
|
get_naver_daily_chart 의 startTime 은 `오늘 - (days+30)` 이므로 같은 경계로 자르면
|
||||||
|
기존 호출과 같은 봉 집합이 된다 → RSI/BB/ATR/ADX 등 룰 지표 값이 변하지 않는다.
|
||||||
|
(ADX 는 Wilder 재귀라 배열 길이에 민감하므로 이 절단이 필수다.)
|
||||||
|
"""
|
||||||
|
if not chart_data:
|
||||||
|
return chart_data
|
||||||
|
cutoff = (datetime.now() - timedelta(days=legacy_days + 30)).strftime("%Y%m%d")
|
||||||
|
trimmed = [
|
||||||
|
item for item in chart_data
|
||||||
|
if str(item.get("stck_bsop_date", "") or "") >= cutoff
|
||||||
|
]
|
||||||
|
return trimmed if trimmed else chart_data
|
||||||
|
|
||||||
|
|
||||||
|
# ─── ML 앙상블 스킵 집계 (2026-09-28) ───────────────────────────────────────
|
||||||
|
# 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 아무 로그도 남지 않았다.
|
||||||
|
# 원인은 `except Exception: pass` 였다. 사유별로 집계해 반드시 눈에 띄게 만든다.
|
||||||
|
_ML_SKIP_STATS: dict = {"total": 0, "reasons": {}, "logged": 0}
|
||||||
|
_ML_SUCCESS_COUNT: dict = {"n": 0}
|
||||||
|
_ML_SKIP_LOG_EVERY = 50 # 최초 1회 + 이후 N회마다 1줄
|
||||||
|
_DIGITS_RE = re.compile(r"\d+")
|
||||||
|
|
||||||
|
|
||||||
|
def get_ml_skip_stats() -> dict:
|
||||||
|
"""ML 스킵 집계 스냅샷 (배치 사이클 종료 시 요약 로그용)."""
|
||||||
|
return {
|
||||||
|
"ml_ok": _ML_SUCCESS_COUNT["n"],
|
||||||
|
"ml_skipped": _ML_SKIP_STATS["total"],
|
||||||
|
"reasons": dict(_ML_SKIP_STATS["reasons"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reset_ml_skip_stats() -> None:
|
||||||
|
"""배치 사이클 시작 시 호출 — 집계 초기화."""
|
||||||
|
_ML_SKIP_STATS["total"] = 0
|
||||||
|
_ML_SKIP_STATS["reasons"] = {}
|
||||||
|
_ML_SKIP_STATS["logged"] = 0
|
||||||
|
_ML_SUCCESS_COUNT["n"] = 0
|
||||||
|
|
||||||
|
|
||||||
|
def log_ml_skip_summary(context: str = "") -> None:
|
||||||
|
"""사이클 종료 시 사유별 집계를 1줄로 남긴다 (warning 수준)."""
|
||||||
|
snap = get_ml_skip_stats()
|
||||||
|
if snap["ml_skipped"] == 0:
|
||||||
|
logger.info("[ML] %s 예측 성공 %d종목, 스킵 0건", context, snap["ml_ok"])
|
||||||
|
return
|
||||||
|
detail = ", ".join(f"{k}={v}" for k, v in sorted(
|
||||||
|
snap["reasons"].items(), key=lambda kv: -kv[1]))
|
||||||
|
logger.warning(
|
||||||
|
"[ML] %s 스킵 %d종목 (성공 %d종목) — 사유별: %s",
|
||||||
|
context, snap["ml_skipped"], snap["ml_ok"], detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_ml_skip(stock_code: str, reason: str) -> None:
|
||||||
|
"""스킵 사유 집계 + 최초/주기적 warning 로그."""
|
||||||
|
# 숫자(봉수 등)를 지워 사유 단위로 묶는다 — "봉수 부족 75/80" 과 "77/80" 이 한 버킷
|
||||||
|
key = _DIGITS_RE.sub("N", reason)[:80] if reason else "unknown"
|
||||||
|
_ML_SKIP_STATS["total"] += 1
|
||||||
|
_ML_SKIP_STATS["reasons"][key] = _ML_SKIP_STATS["reasons"].get(key, 0) + 1
|
||||||
|
n = _ML_SKIP_STATS["total"]
|
||||||
|
if n == 1 or n % _ML_SKIP_LOG_EVERY == 0:
|
||||||
|
_ML_SKIP_STATS["logged"] += 1
|
||||||
|
logger.warning("[ML] 예측 스킵 %s: %s (누적 %d건)", stock_code, reason, n)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ml_ensemble(chart_data: List[dict], stock_code: str) -> Optional[dict]:
|
||||||
|
"""ML 앙상블 예측 — 실패는 집계·로그로 드러내고 None 을 반환한다.
|
||||||
|
|
||||||
|
ML 실패가 룰 기반 분석 저장을 막지 않도록 여기서 흡수하되,
|
||||||
|
**조용히 넘기지 않는다**(기존 `except Exception: pass` 가 9/23 무증상의 원인).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.services.ml_ensemble import get_ensemble
|
||||||
|
ensemble = get_ensemble()
|
||||||
|
if not ensemble.is_trained:
|
||||||
|
_record_ml_skip(stock_code, "모델 미학습")
|
||||||
|
return None
|
||||||
|
ml_result = ensemble.predict(chart_data) # 예외를 던지지 않는 계약
|
||||||
|
if "error" in ml_result:
|
||||||
|
_record_ml_skip(stock_code, f"{ml_result['error']} (봉수 {len(chart_data)})")
|
||||||
|
return None
|
||||||
|
if ml_result.get("degraded_models"):
|
||||||
|
logger.warning(
|
||||||
|
"[ML] %s 일부 모델 제외 상태로 예측: %s",
|
||||||
|
stock_code, ml_result["degraded_models"],
|
||||||
|
)
|
||||||
|
_ML_SUCCESS_COUNT["n"] += 1
|
||||||
|
return ml_result
|
||||||
|
except Exception as e:
|
||||||
|
# predict() 는 예외를 내지 않아야 하므로 여기에 오면 계약 위반 = 버그다.
|
||||||
|
logger.warning(
|
||||||
|
"[ML] %s 예측 중 예상치 못한 예외 (%s): %s",
|
||||||
|
stock_code, type(e).__name__, e, exc_info=True,
|
||||||
|
)
|
||||||
|
_record_ml_skip(stock_code, f"예외 {type(e).__name__}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_analysis_result(
|
def _build_analysis_result(
|
||||||
chart_data: List[dict],
|
chart_data: List[dict],
|
||||||
chart_info: dict,
|
chart_info: dict,
|
||||||
indicators: dict,
|
indicators: dict,
|
||||||
criteria: "RecommendationCriteria",
|
criteria: "RecommendationCriteria",
|
||||||
) -> Optional[StockRecommendation]:
|
) -> Optional[StockRecommendation]:
|
||||||
"""Build analysis result with signals and score.
|
"""Build analysis result with signals and score. (기존 시그니처 유지)"""
|
||||||
|
return _build_analysis_result_v2(chart_data, chart_info, indicators, criteria)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_analysis_result_v2(
|
||||||
|
chart_data: List[dict],
|
||||||
|
chart_info: dict,
|
||||||
|
indicators: dict,
|
||||||
|
criteria: "RecommendationCriteria",
|
||||||
|
ml_chart_data: Optional[List[dict]] = None,
|
||||||
|
) -> Optional[StockRecommendation]:
|
||||||
|
"""_build_analysis_result + ML 전용 장기 차트 주입 (2026-09-28).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ml_chart_data: ML 앙상블에만 사용할 더 긴 차트(≥80봉). None 이면 chart_data 사용.
|
||||||
|
**룰 지표(RSI/BB/ATR/ADX)는 chart_data 만으로 계산되므로 값이 변하지 않는다.**
|
||||||
|
ADX(Wilder)는 배열 전체를 훑는 재귀 지표라 봉수가 바뀌면 값이 달라진다 —
|
||||||
|
그래서 룰 경로의 입력은 절대 늘리지 않고 ML 입력만 분리했다.
|
||||||
|
|
||||||
Returns StockRecommendation or None based on score threshold and criteria.
|
Returns StockRecommendation or None based on score threshold and criteria.
|
||||||
"""
|
"""
|
||||||
@@ -590,26 +722,21 @@ def _build_analysis_result(
|
|||||||
ml_signal_val = None
|
ml_signal_val = None
|
||||||
|
|
||||||
if settings.ML_ENSEMBLE_ENABLED:
|
if settings.ML_ENSEMBLE_ENABLED:
|
||||||
try:
|
# ML 결과는 chart_data 보다 긴 시계열이 있으면 그쪽을 쓴다 (봉수 부족 방지).
|
||||||
from app.services.ml_ensemble import get_ensemble
|
ml_result = _run_ml_ensemble(ml_chart_data or chart_data, stock_code)
|
||||||
ensemble = get_ensemble()
|
if ml_result is not None:
|
||||||
if ensemble.is_trained:
|
ml_score_val = ml_result["ensemble_score"]
|
||||||
ml_result = ensemble.predict(chart_data)
|
ml_confidence_val = ml_result["confidence"]
|
||||||
if "error" not in ml_result:
|
ml_signal_val = ml_result["signal"]
|
||||||
ml_score_val = ml_result["ensemble_score"]
|
|
||||||
ml_confidence_val = ml_result["confidence"]
|
|
||||||
ml_signal_val = ml_result["signal"]
|
|
||||||
|
|
||||||
rule_score_normalized = (score / 15.0) * 100.0
|
rule_score_normalized = (score / 15.0) * 100.0
|
||||||
combined_score = int(
|
combined_score = int(
|
||||||
rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT +
|
rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT +
|
||||||
ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT
|
ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT
|
||||||
)
|
)
|
||||||
score = max(0, int(combined_score / 100.0 * 15.0))
|
score = max(0, int(combined_score / 100.0 * 15.0))
|
||||||
|
|
||||||
signals.append(f"ML앙상블: {ml_signal_val}({ml_score_val}점, 신뢰도{ml_confidence_val:.0%})")
|
signals.append(f"ML앙상블: {ml_signal_val}({ml_score_val}점, 신뢰도{ml_confidence_val:.0%})")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if criteria.for_batch or score >= 2:
|
if criteria.for_batch or score >= 2:
|
||||||
final_score = max(1, score) if len(signals) > 0 else score
|
final_score = max(1, score) if len(signals) > 0 else score
|
||||||
@@ -656,6 +783,20 @@ def _analyze_chart_items(
|
|||||||
2. 기술적 지표 계산
|
2. 기술적 지표 계산
|
||||||
3. 분석 결과 생성 (신호 및 점수)
|
3. 분석 결과 생성 (신호 및 점수)
|
||||||
"""
|
"""
|
||||||
|
return _analyze_chart_items_v2(chart_data, stock_code, stock_name, criteria)
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_chart_items_v2(
|
||||||
|
chart_data: List[dict],
|
||||||
|
stock_code: str,
|
||||||
|
stock_name: str,
|
||||||
|
criteria: "RecommendationCriteria",
|
||||||
|
ml_chart_data: Optional[List[dict]] = None,
|
||||||
|
) -> Optional[StockRecommendation]:
|
||||||
|
"""_analyze_chart_items + ML 전용 장기 차트 주입 (2026-09-28).
|
||||||
|
|
||||||
|
ml_chart_data 는 ML 앙상블에만 전달되며 룰 지표 계산에는 쓰이지 않는다.
|
||||||
|
"""
|
||||||
# 1. Extract and validate chart data
|
# 1. Extract and validate chart data
|
||||||
chart_info = _extract_chart_data(chart_data, stock_code, stock_name)
|
chart_info = _extract_chart_data(chart_data, stock_code, stock_name)
|
||||||
if not chart_info:
|
if not chart_info:
|
||||||
@@ -665,7 +806,9 @@ def _analyze_chart_items(
|
|||||||
indicators = _calculate_technical_indicators(chart_data, chart_info["closes"])
|
indicators = _calculate_technical_indicators(chart_data, chart_info["closes"])
|
||||||
|
|
||||||
# 3. Build analysis result with signals and scoring
|
# 3. Build analysis result with signals and scoring
|
||||||
return _build_analysis_result(chart_data, chart_info, indicators, criteria)
|
return _build_analysis_result_v2(
|
||||||
|
chart_data, chart_info, indicators, criteria, ml_chart_data=ml_chart_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]:
|
async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]:
|
||||||
@@ -676,24 +819,35 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
chart_data = None
|
chart_data = None
|
||||||
|
ml_chart_data = None
|
||||||
|
|
||||||
# 배치 분석: 네이버 일봉 우선 사용
|
# 배치 분석: 네이버 일봉 우선 사용
|
||||||
if criteria.for_batch:
|
if criteria.for_batch:
|
||||||
try:
|
try:
|
||||||
from app.services.naver_finance import get_naver_daily_chart
|
from app.services.naver_finance import get_naver_daily_chart
|
||||||
chart_data = await get_naver_daily_chart(stock_code)
|
# ML 은 최소 80봉이 필요하다(시퀀스60+워밍업20). 기존 기본값 days=80 은
|
||||||
except Exception:
|
# 캘린더 110일 창 → 실제 75~77봉밖에 오지 않아 ML 이 항상 NaN 으로 터졌다.
|
||||||
pass
|
ml_chart_data = await get_naver_daily_chart(
|
||||||
|
stock_code, days=_NAVER_ML_CHART_DAYS,
|
||||||
|
)
|
||||||
|
# 룰 지표는 기존과 동일한 창으로 계산한다(ADX Wilder 는 봉수 의존).
|
||||||
|
chart_data = _trim_to_legacy_window(ml_chart_data, _NAVER_LEGACY_DAYS)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("네이버 일봉 조회 실패 %s: %s", stock_code, e)
|
||||||
|
|
||||||
# 네이버 실패 또는 실시간 분석 → KIS API 폴백
|
# 네이버 실패 또는 실시간 분석 → KIS API 폴백
|
||||||
if chart_data is None:
|
if chart_data is None:
|
||||||
_api = _real_market_api or korea_investment_api
|
_api = _real_market_api or korea_investment_api
|
||||||
|
# limit 기본값 240 — ML 최소 요구치(80봉)를 충분히 넘는다
|
||||||
chart_response = await _api.get_daily_chart(stock_code, "D")
|
chart_response = await _api.get_daily_chart(stock_code, "D")
|
||||||
if "output2" not in chart_response or not chart_response["output2"]:
|
if "output2" not in chart_response or not chart_response["output2"]:
|
||||||
return None
|
return None
|
||||||
chart_data = list(reversed(chart_response["output2"]))
|
chart_data = list(reversed(chart_response["output2"]))
|
||||||
|
ml_chart_data = None # KIS 경로는 동일 배열을 그대로 ML 에 사용
|
||||||
|
|
||||||
return _analyze_chart_items(chart_data, stock_code, stock_name, criteria)
|
return _analyze_chart_items_v2(
|
||||||
|
chart_data, stock_code, stock_name, criteria, ml_chart_data=ml_chart_data,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error analyzing {stock_code}: {str(e)}")
|
logger.error(f"Error analyzing {stock_code}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import joblib
|
|||||||
from app.services.feature_engineering import (
|
from app.services.feature_engineering import (
|
||||||
FEATURE_COLUMNS,
|
FEATURE_COLUMNS,
|
||||||
LSTM_SEQUENCE_FEATURES,
|
LSTM_SEQUENCE_FEATURES,
|
||||||
|
LSTM_SEQUENCE_LENGTH,
|
||||||
|
MIN_BARS_FOR_PREDICT,
|
||||||
extract_single_features,
|
extract_single_features,
|
||||||
|
extract_single_features_v2,
|
||||||
chart_data_to_dataframe,
|
chart_data_to_dataframe,
|
||||||
extract_features,
|
extract_features,
|
||||||
prepare_training_data,
|
prepare_training_data,
|
||||||
@@ -33,6 +36,35 @@ logger = logging.getLogger(__name__)
|
|||||||
MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble"
|
MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble"
|
||||||
|
|
||||||
|
|
||||||
|
_CLASS_NAMES = ("down", "hold", "up")
|
||||||
|
|
||||||
|
|
||||||
|
def _per_class_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, Dict[str, float]]:
|
||||||
|
"""3클래스 precision/recall/support (sklearn 의존 없이 계산).
|
||||||
|
|
||||||
|
모델 교체 판단의 근거로 metrics.json 에 기록한다.
|
||||||
|
정확도만으로는 "전부 HOLD" 모델이 좋아 보이는 문제를 잡을 수 없다.
|
||||||
|
"""
|
||||||
|
y_true = np.asarray(y_true, dtype=int)
|
||||||
|
y_pred = np.asarray(y_pred, dtype=int)
|
||||||
|
out: Dict[str, Dict[str, float]] = {}
|
||||||
|
for cls, name in enumerate(_CLASS_NAMES):
|
||||||
|
tp = int(np.sum((y_pred == cls) & (y_true == cls)))
|
||||||
|
fp = int(np.sum((y_pred == cls) & (y_true != cls)))
|
||||||
|
fn = int(np.sum((y_pred != cls) & (y_true == cls)))
|
||||||
|
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
||||||
|
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
||||||
|
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0
|
||||||
|
out[name] = {
|
||||||
|
"precision": round(float(precision), 4),
|
||||||
|
"recall": round(float(recall), 4),
|
||||||
|
"f1": round(float(f1), 4),
|
||||||
|
"support": int(np.sum(y_true == cls)),
|
||||||
|
"predicted": int(np.sum(y_pred == cls)),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ─── PyTorch LSTM 모델 정의 ───
|
# ─── PyTorch LSTM 모델 정의 ───
|
||||||
|
|
||||||
def _get_torch():
|
def _get_torch():
|
||||||
@@ -251,14 +283,32 @@ class MLEnsemble:
|
|||||||
Returns:
|
Returns:
|
||||||
학습 메트릭
|
학습 메트릭
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
self._ensure_dir()
|
self._ensure_dir()
|
||||||
metrics = {}
|
metrics: Dict[str, Any] = {}
|
||||||
|
|
||||||
if len(X_tabular) < 50:
|
if len(X_tabular) < 50:
|
||||||
return {"error": f"학습 데이터 부족: {len(X_tabular)}개 (최소 50개 필요)"}
|
return {"error": f"학습 데이터 부족: {len(X_tabular)}개 (최소 50개 필요)"}
|
||||||
|
|
||||||
logger.info(f"앙상블 학습 시작: {len(X_tabular)}개 샘플, 클래스 분포: {np.bincount(y)}")
|
logger.info(f"앙상블 학습 시작: {len(X_tabular)}개 샘플, 클래스 분포: {np.bincount(y)}")
|
||||||
|
|
||||||
|
# ─── 학습 메타데이터 (모델 품질·교체 판단의 근거) ───
|
||||||
|
_KST = timezone(timedelta(hours=9))
|
||||||
|
_class_counts = np.bincount(np.asarray(y, dtype=int), minlength=3)
|
||||||
|
metrics["trained_at"] = datetime.now(_KST).isoformat()
|
||||||
|
metrics["n_samples"] = int(len(X_tabular))
|
||||||
|
metrics["n_features"] = int(X_tabular.shape[1]) if X_tabular.ndim > 1 else 0
|
||||||
|
metrics["sequence_shape"] = list(np.asarray(X_seq).shape[1:]) if len(X_seq) else []
|
||||||
|
metrics["class_distribution"] = {
|
||||||
|
"down": int(_class_counts[0]), "hold": int(_class_counts[1]), "up": int(_class_counts[2]),
|
||||||
|
}
|
||||||
|
metrics["class_ratio"] = {
|
||||||
|
k: round(float(v) / max(1, len(y)), 4)
|
||||||
|
for k, v in zip(("down", "hold", "up"), _class_counts[:3])
|
||||||
|
}
|
||||||
|
metrics["weights"] = {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]}
|
||||||
|
|
||||||
# 탭뷸러 피처 스케일링
|
# 탭뷸러 피처 스케일링
|
||||||
from sklearn.preprocessing import StandardScaler
|
from sklearn.preprocessing import StandardScaler
|
||||||
self.scaler = StandardScaler()
|
self.scaler = StandardScaler()
|
||||||
@@ -272,6 +322,12 @@ class MLEnsemble:
|
|||||||
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
|
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
|
||||||
y_train, y_val = y[:split_idx], y[split_idx:]
|
y_train, y_val = y[:split_idx], y[split_idx:]
|
||||||
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
|
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
|
||||||
|
metrics["n_train"] = int(len(X_train))
|
||||||
|
metrics["n_val"] = int(len(X_val))
|
||||||
|
_val_counts = np.bincount(np.asarray(y_val, dtype=int), minlength=3)
|
||||||
|
metrics["val_class_distribution"] = {
|
||||||
|
"down": int(_val_counts[0]), "hold": int(_val_counts[1]), "up": int(_val_counts[2]),
|
||||||
|
}
|
||||||
|
|
||||||
# 1. LightGBM
|
# 1. LightGBM
|
||||||
logger.info(">>> LightGBM 학습 중...")
|
logger.info(">>> LightGBM 학습 중...")
|
||||||
@@ -299,6 +355,7 @@ class MLEnsemble:
|
|||||||
lgbm_val_pred = self.lgbm_model.predict(X_val)
|
lgbm_val_pred = self.lgbm_model.predict(X_val)
|
||||||
lgbm_acc = np.mean(lgbm_val_pred == y_val)
|
lgbm_acc = np.mean(lgbm_val_pred == y_val)
|
||||||
metrics["lgbm_accuracy"] = float(lgbm_acc)
|
metrics["lgbm_accuracy"] = float(lgbm_acc)
|
||||||
|
metrics["lgbm_per_class"] = _per_class_metrics(y_val, lgbm_val_pred)
|
||||||
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
|
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
|
||||||
|
|
||||||
importance = self.lgbm_model.feature_importances_
|
importance = self.lgbm_model.feature_importances_
|
||||||
@@ -331,7 +388,12 @@ class MLEnsemble:
|
|||||||
lstm_val_pred = np.argmax(lstm_val_probs, axis=1)
|
lstm_val_pred = np.argmax(lstm_val_probs, axis=1)
|
||||||
lstm_acc = np.mean(lstm_val_pred == y_val)
|
lstm_acc = np.mean(lstm_val_pred == y_val)
|
||||||
metrics["lstm_accuracy"] = float(lstm_acc)
|
metrics["lstm_accuracy"] = float(lstm_acc)
|
||||||
|
metrics["lstm_per_class"] = _per_class_metrics(y_val, lstm_val_pred)
|
||||||
metrics["lstm_training"] = lstm_metrics
|
metrics["lstm_training"] = lstm_metrics
|
||||||
|
# seq_std 가 0 에 수렴하면(예: 전부 동일값) 정규화가 폭주한다 → 기록해 둔다
|
||||||
|
if self.lstm_model.seq_std is not None:
|
||||||
|
metrics["lstm_seq_std_min"] = float(np.min(self.lstm_model.seq_std))
|
||||||
|
metrics["lstm_seq_mean_abs_max"] = float(np.max(np.abs(self.lstm_model.seq_mean)))
|
||||||
logger.info(f"LSTM 검증 정확도: {lstm_acc:.4f}")
|
logger.info(f"LSTM 검증 정확도: {lstm_acc:.4f}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LSTM 학습 실패: {e}")
|
logger.error(f"LSTM 학습 실패: {e}")
|
||||||
@@ -353,54 +415,86 @@ class MLEnsemble:
|
|||||||
rf_val_pred = self.rf_model.predict(X_val)
|
rf_val_pred = self.rf_model.predict(X_val)
|
||||||
rf_acc = np.mean(rf_val_pred == y_val)
|
rf_acc = np.mean(rf_val_pred == y_val)
|
||||||
metrics["rf_accuracy"] = float(rf_acc)
|
metrics["rf_accuracy"] = float(rf_acc)
|
||||||
|
metrics["rf_per_class"] = _per_class_metrics(y_val, rf_val_pred)
|
||||||
logger.info(f"Random Forest 검증 정확도: {rf_acc:.4f}")
|
logger.info(f"Random Forest 검증 정확도: {rf_acc:.4f}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Random Forest 학습 실패: {e}")
|
logger.error(f"Random Forest 학습 실패: {e}")
|
||||||
metrics["rf_error"] = str(e)
|
metrics["rf_error"] = str(e)
|
||||||
|
|
||||||
# 앙상블 검증
|
# 앙상블 검증
|
||||||
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val)
|
_degraded: list = []
|
||||||
|
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val, degraded=_degraded)
|
||||||
|
if _degraded:
|
||||||
|
metrics["degraded_models"] = _degraded
|
||||||
if ensemble_probs is not None:
|
if ensemble_probs is not None:
|
||||||
ensemble_pred = np.argmax(ensemble_probs, axis=1)
|
ensemble_pred = np.argmax(ensemble_probs, axis=1)
|
||||||
ensemble_acc = np.mean(ensemble_pred == y_val)
|
ensemble_acc = np.mean(ensemble_pred == y_val)
|
||||||
metrics["ensemble_accuracy"] = float(ensemble_acc)
|
metrics["ensemble_accuracy"] = float(ensemble_acc)
|
||||||
|
metrics["ensemble_per_class"] = _per_class_metrics(y_val, ensemble_pred)
|
||||||
|
_pred_counts = np.bincount(np.asarray(ensemble_pred, dtype=int), minlength=3)
|
||||||
|
metrics["ensemble_pred_distribution"] = {
|
||||||
|
"down": int(_pred_counts[0]), "hold": int(_pred_counts[1]), "up": int(_pred_counts[2]),
|
||||||
|
}
|
||||||
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
|
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
|
||||||
|
else:
|
||||||
|
metrics["ensemble_error"] = "모든 모델 예측 실패"
|
||||||
|
|
||||||
self._save_models()
|
# ⚠️ 순서 중요: _save_models() 가 self.training_metrics 를 metrics.json 으로
|
||||||
|
# 쓴다. 예전에는 저장이 대입보다 **먼저** 실행돼 항상 이전 값({})이 기록됐고,
|
||||||
|
# 그래서 운영 metrics.json 이 빈 dict 였다. (2026-09-28 수정)
|
||||||
self.is_trained = True
|
self.is_trained = True
|
||||||
self.training_metrics = metrics
|
self.training_metrics = metrics
|
||||||
|
self._save_models()
|
||||||
|
|
||||||
logger.info(f"앙상블 학습 완료: {metrics}")
|
logger.info(f"앙상블 학습 완료: {metrics}")
|
||||||
return metrics
|
return metrics
|
||||||
|
|
||||||
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray) -> Optional[np.ndarray]:
|
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray,
|
||||||
"""3개 모델의 가중 평균 확률"""
|
degraded: Optional[list] = None) -> Optional[np.ndarray]:
|
||||||
|
"""3개 모델의 가중 평균 확률.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
degraded: (선택) 제외된 모델 사유를 담을 리스트. 호출부가 넘기면
|
||||||
|
"lgbm:non-finite" 같은 문자열이 추가된다.
|
||||||
|
|
||||||
|
비유한(NaN/inf) 확률을 뱉는 모델은 앙상블에서 **제외**한다.
|
||||||
|
예전에는 NaN 이 그대로 가중평균에 섞여 최종 확률 전체를 NaN 으로 만들고
|
||||||
|
`int(prob_up*100)` 에서 예외로 터졌다. 제외 사실은 로그와 degraded 로
|
||||||
|
드러내며, 조용히 넘기지 않는다.
|
||||||
|
"""
|
||||||
probs_list = []
|
probs_list = []
|
||||||
weights_list = []
|
weights_list = []
|
||||||
|
|
||||||
|
def _record(name: str, reason: str):
|
||||||
|
logger.warning("[ML] %s 모델 제외: %s", name, reason)
|
||||||
|
if degraded is not None:
|
||||||
|
degraded.append(f"{name}:{reason}")
|
||||||
|
|
||||||
|
def _add(name: str, probs, weight: float):
|
||||||
|
arr = np.asarray(probs, dtype=float)
|
||||||
|
if not np.all(np.isfinite(arr)):
|
||||||
|
_record(name, "non-finite")
|
||||||
|
return
|
||||||
|
probs_list.append(arr)
|
||||||
|
weights_list.append(weight)
|
||||||
|
|
||||||
if self.lgbm_model is not None:
|
if self.lgbm_model is not None:
|
||||||
try:
|
try:
|
||||||
lgbm_probs = self.lgbm_model.predict_proba(X_tabular)
|
_add("lgbm", self.lgbm_model.predict_proba(X_tabular), self.weights[0])
|
||||||
probs_list.append(lgbm_probs)
|
except Exception as e:
|
||||||
weights_list.append(self.weights[0])
|
_record("lgbm", f"{type(e).__name__}: {e}")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self.lstm_model is not None and self.lstm_model.model is not None:
|
if self.lstm_model is not None and self.lstm_model.model is not None:
|
||||||
try:
|
try:
|
||||||
lstm_probs = self.lstm_model.predict_proba(X_seq)
|
_add("lstm", self.lstm_model.predict_proba(X_seq), self.weights[1])
|
||||||
probs_list.append(lstm_probs)
|
except Exception as e:
|
||||||
weights_list.append(self.weights[1])
|
_record("lstm", f"{type(e).__name__}: {e}")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self.rf_model is not None:
|
if self.rf_model is not None:
|
||||||
try:
|
try:
|
||||||
rf_probs = self.rf_model.predict_proba(X_tabular)
|
_add("rf", self.rf_model.predict_proba(X_tabular), self.weights[2])
|
||||||
probs_list.append(rf_probs)
|
except Exception as e:
|
||||||
weights_list.append(self.weights[2])
|
_record("rf", f"{type(e).__name__}: {e}")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not probs_list:
|
if not probs_list:
|
||||||
return None
|
return None
|
||||||
@@ -421,22 +515,63 @@ class MLEnsemble:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{ensemble_score, confidence, signal, model_scores, probabilities}
|
{ensemble_score, confidence, signal, model_scores, probabilities}
|
||||||
|
또는 {"error": 사유, ...} — **어떤 입력에도 예외를 던지지 않는다.**
|
||||||
|
|
||||||
|
주의: 호출부는 반드시 `"error" in result` 를 확인해야 한다.
|
||||||
|
error 응답의 ensemble_score=50 은 "중립 플레이스홀더"이며 모델 판단이 아니다.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
return self._predict_impl(chart_data)
|
||||||
|
except Exception as e:
|
||||||
|
# predict() 는 절대 예외를 밖으로 내지 않는다. 호출부의
|
||||||
|
# `except Exception: pass` 에 삼켜져 5일간 무증상이던 것이 9/23 사고였다.
|
||||||
|
logger.warning("[ML] predict 예외 (%s): %s", type(e).__name__, e, exc_info=True)
|
||||||
|
return {
|
||||||
|
"error": f"예측 예외 {type(e).__name__}: {e}",
|
||||||
|
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _predict_impl(self, chart_data: list) -> Dict[str, Any]:
|
||||||
|
"""predict() 의 실제 구현 (예외 가드는 predict() 가 담당)."""
|
||||||
if not self.is_trained:
|
if not self.is_trained:
|
||||||
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||||
|
if self.scaler is None:
|
||||||
|
return {"error": "스케일러 없음", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
||||||
|
|
||||||
tabular, sequence = extract_single_features(chart_data)
|
tabular, sequence, reason = extract_single_features_v2(chart_data)
|
||||||
if tabular is None or sequence is None:
|
if tabular is None or sequence is None:
|
||||||
return {"error": "피처 추출 실패 (데이터 부족)", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
return {
|
||||||
|
"error": f"피처 추출 실패: {reason or '데이터 부족'}",
|
||||||
|
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
|
||||||
|
}
|
||||||
|
|
||||||
tabular_scaled = self.scaler.transform(tabular.reshape(1, -1))
|
tabular_scaled = np.asarray(self.scaler.transform(tabular.reshape(1, -1)), dtype=float)
|
||||||
sequence_batch = sequence.reshape(1, 60, len(LSTM_SEQUENCE_FEATURES))
|
if not np.all(np.isfinite(tabular_scaled)):
|
||||||
|
# 스케일러 분산 0 등으로 스케일링 결과가 깨진 경우 — 점수를 만들지 않는다
|
||||||
|
return {
|
||||||
|
"error": "스케일링 결과 비유한(NaN/inf)",
|
||||||
|
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
|
||||||
|
}
|
||||||
|
sequence_batch = sequence.reshape(1, LSTM_SEQUENCE_LENGTH, len(LSTM_SEQUENCE_FEATURES))
|
||||||
|
|
||||||
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch)
|
degraded: list = []
|
||||||
|
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch, degraded=degraded)
|
||||||
if probs is None:
|
if probs is None:
|
||||||
return {"error": "모든 모델 예측 실패", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
|
return {
|
||||||
|
"error": "모든 모델 예측 실패" + (f" ({','.join(degraded)})" if degraded else ""),
|
||||||
|
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
|
||||||
|
}
|
||||||
|
|
||||||
prob_down, prob_hold, prob_up = probs[0]
|
probs = np.asarray(probs, dtype=float)
|
||||||
|
if probs.shape[0] < 1 or probs.shape[1] < 3 or not np.all(np.isfinite(probs[0])):
|
||||||
|
# NaN 을 0 이나 그럴듯한 값으로 치환하지 않는다 — 0점은 "ML이 매수 반대"로
|
||||||
|
# 오해되므로, 정직하게 error 를 돌려 호출부가 ML 없이 진행하게 한다.
|
||||||
|
return {
|
||||||
|
"error": "앙상블 확률 비유한(NaN/inf)",
|
||||||
|
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
|
||||||
|
}
|
||||||
|
|
||||||
|
prob_down, prob_hold, prob_up = probs[0][:3]
|
||||||
ensemble_score = int(prob_up * 100)
|
ensemble_score = int(prob_up * 100)
|
||||||
max_prob = max(prob_down, prob_hold, prob_up)
|
max_prob = max(prob_down, prob_hold, prob_up)
|
||||||
confidence = float(max_prob)
|
confidence = float(max_prob)
|
||||||
@@ -473,6 +608,8 @@ class MLEnsemble:
|
|||||||
"confidence": round(confidence, 4),
|
"confidence": round(confidence, 4),
|
||||||
"signal": signal,
|
"signal": signal,
|
||||||
"model_scores": model_scores,
|
"model_scores": model_scores,
|
||||||
|
# 비유한 확률/예외로 앙상블에서 빠진 모델 — 비어 있어야 정상
|
||||||
|
"degraded_models": degraded,
|
||||||
"probabilities": {
|
"probabilities": {
|
||||||
"up": round(float(prob_up), 4),
|
"up": round(float(prob_up), 4),
|
||||||
"hold": round(float(prob_hold), 4),
|
"hold": round(float(prob_hold), 4),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from decimal import Decimal
|
|||||||
from sqlalchemy import select, and_, func
|
from sqlalchemy import select, and_, func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.models import TradeHistory, TradeJournal
|
from app.models.models import TradeHistory, TradeJournal, StockAnalysis, Portfolio
|
||||||
from app.core.timeutil import now_kst
|
from app.core.timeutil import now_kst
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -360,6 +360,39 @@ def _detect_drift_alerts(trades: List["TradeHistory"]) -> List[Dict[str, Any]]:
|
|||||||
# 핵심 분석 함수
|
# 핵심 분석 함수
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _resolve_stock_names(db: AsyncSession, codes: List[str]) -> Dict[str, str]:
|
||||||
|
"""trade_history.stock_name 이 비어 있는 종목의 종목명을 보강 조회.
|
||||||
|
|
||||||
|
stock_analysis → portfolio 순으로 조회하며, 없으면 키를 만들지 않는다(호출부에서 코드로 대체).
|
||||||
|
"""
|
||||||
|
names: Dict[str, str] = {}
|
||||||
|
if not codes:
|
||||||
|
return names
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = await db.execute(
|
||||||
|
select(StockAnalysis.stock_code, StockAnalysis.stock_name)
|
||||||
|
.where(StockAnalysis.stock_code.in_(codes))
|
||||||
|
)
|
||||||
|
for code, name in rows.all():
|
||||||
|
if name and code not in names:
|
||||||
|
names[code] = name
|
||||||
|
|
||||||
|
remaining = [c for c in codes if c not in names]
|
||||||
|
if remaining:
|
||||||
|
rows = await db.execute(
|
||||||
|
select(Portfolio.stock_code, Portfolio.stock_name)
|
||||||
|
.where(Portfolio.stock_code.in_(remaining))
|
||||||
|
)
|
||||||
|
for code, name in rows.all():
|
||||||
|
if name and code not in names:
|
||||||
|
names[code] = name
|
||||||
|
except Exception as e: # 종목명은 부가 정보이므로 실패해도 분석은 계속
|
||||||
|
logger.warning(f"종목명 보강 조회 실패: {e}")
|
||||||
|
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any]:
|
async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
최근 N일 완료 거래 성과 분석.
|
최근 N일 완료 거래 성과 분석.
|
||||||
@@ -370,8 +403,8 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
|||||||
"by_strategy": {전략별 성과},
|
"by_strategy": {전략별 성과},
|
||||||
"by_weekday": {요일별 성과},
|
"by_weekday": {요일별 성과},
|
||||||
"by_exit_type": {청산 유형별 통계},
|
"by_exit_type": {청산 유형별 통계},
|
||||||
"worst_stocks": [최대 손실 종목 Top5],
|
"worst_stocks": [최대 손실 종목 Top5 {stock_code, stock_name, net_pnl, trade_count}],
|
||||||
"best_stocks": [최대 수익 종목 Top5],
|
"best_stocks": [최대 수익 종목 Top5 {stock_code, stock_name, net_pnl, trade_count}],
|
||||||
"sharpe_ratio": float | None (연환산, Rf=3%),
|
"sharpe_ratio": float | None (연환산, Rf=3%),
|
||||||
"sortino_ratio": float | None (하방편차만),
|
"sortino_ratio": float | None (하방편차만),
|
||||||
"calmar_ratio": float | None (CAGR / |MDD|),
|
"calmar_ratio": float | None (CAGR / |MDD|),
|
||||||
@@ -498,14 +531,33 @@ async def analyze_performance(db: AsyncSession, days: int = 30) -> Dict[str, Any
|
|||||||
|
|
||||||
# ── 종목별 집계 ──────────────────────────────────────────────────────
|
# ── 종목별 집계 ──────────────────────────────────────────────────────
|
||||||
stock_pnl: Dict[str, float] = {}
|
stock_pnl: Dict[str, float] = {}
|
||||||
|
stock_names: Dict[str, str] = {}
|
||||||
|
stock_trade_count: Dict[str, int] = {}
|
||||||
for t in trades:
|
for t in trades:
|
||||||
code = t.stock_code
|
code = t.stock_code
|
||||||
stock_pnl[code] = stock_pnl.get(code, 0.0) + _to_float(t.net_profit_loss)
|
stock_pnl[code] = stock_pnl.get(code, 0.0) + _to_float(t.net_profit_loss)
|
||||||
|
stock_trade_count[code] = stock_trade_count.get(code, 0) + 1
|
||||||
|
# 종목명은 최신 거래 기준(trades는 created_at 오름차순)으로 갱신
|
||||||
|
if t.stock_name:
|
||||||
|
stock_names[code] = t.stock_name
|
||||||
|
|
||||||
|
# trade_history.stock_name 이 비어 있는 종목은 stock_analysis 에서 보강
|
||||||
|
missing = [c for c in stock_pnl if c not in stock_names]
|
||||||
|
if missing:
|
||||||
|
stock_names.update(await _resolve_stock_names(db, missing))
|
||||||
|
|
||||||
|
def _stock_row(code: str, pnl: float) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"stock_code": code,
|
||||||
|
"stock_name": stock_names.get(code) or code,
|
||||||
|
"net_pnl": round(pnl, 2),
|
||||||
|
"trade_count": stock_trade_count.get(code, 0),
|
||||||
|
}
|
||||||
|
|
||||||
sorted_stocks = sorted(stock_pnl.items(), key=lambda x: x[1])
|
sorted_stocks = sorted(stock_pnl.items(), key=lambda x: x[1])
|
||||||
# 손실 종목만 (음수), 수익 종목만 (양수) 필터링하여 색상 혼동 방지
|
# 손실 종목만 (음수), 수익 종목만 (양수) 필터링하여 색상 혼동 방지
|
||||||
worst_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks if p < 0][:5]
|
worst_stocks = [_stock_row(c, p) for c, p in sorted_stocks if p < 0][:5]
|
||||||
best_stocks = [{"stock_code": c, "net_pnl": round(p, 2)} for c, p in sorted_stocks[::-1] if p > 0][:5]
|
best_stocks = [_stock_row(c, p) for c, p in sorted_stocks[::-1] if p > 0][:5]
|
||||||
|
|
||||||
# ── 리스크 조정 수익률 지표 ────────────────────────────────────────────
|
# ── 리스크 조정 수익률 지표 ────────────────────────────────────────────
|
||||||
risk_metrics = _calc_risk_adjusted_metrics(trades)
|
risk_metrics = _calc_risk_adjusted_metrics(trades)
|
||||||
|
|||||||
@@ -1787,6 +1787,39 @@ async def _delayed_sync_portfolio(delay_seconds: int = 3):
|
|||||||
logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}")
|
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):
|
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:
|
if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None:
|
||||||
try:
|
try:
|
||||||
weighted_score = (
|
# [2026-09-28] 스케일 불일치(0~15 룰점수 vs 0~100 임계) 및 ML 이중
|
||||||
score_val * settings.ML_ENSEMBLE_RULE_WEIGHT
|
# 계산 수정. ML_SCORE_NORMALIZE_ENABLED=False 면 기존 계산과 동일.
|
||||||
+ float(ml_score_trade) * settings.ML_ENSEMBLE_ML_WEIGHT
|
# HOLD 게이트(*0.7)도 같은 weighted_score 를 쓰므로 스케일 일관.
|
||||||
)
|
weighted_score = _ml_gate_score_v2(score_val, float(ml_score_trade))
|
||||||
if (
|
if (
|
||||||
ml_signal_trade == "BUY"
|
ml_signal_trade == "BUY"
|
||||||
and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD
|
and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ from sqlalchemy import select as sa_select, func as sa_func
|
|||||||
from app.models.models import StockDailyPrice
|
from app.models.models import StockDailyPrice
|
||||||
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||||||
from app.services.market_analyzer import analyze_stock_for_batch_db, StockRecommendation
|
from app.services.market_analyzer import analyze_stock_for_batch_db, StockRecommendation
|
||||||
|
from app.services.market_analyzer import (
|
||||||
|
reset_ml_skip_stats, log_ml_skip_summary,
|
||||||
|
)
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
# 🔧 [fix] logging.basicConfig는 __main__ 블록에서만 호출
|
# 🔧 [fix] logging.basicConfig는 __main__ 블록에서만 호출
|
||||||
@@ -516,6 +519,10 @@ async def batch_analyze_all_stocks(limit: int = None):
|
|||||||
logger.info("⚙️ 동시 처리: %d종목 | 벌크저장 단위: %d개 | API 세마포어: %dreq",
|
logger.info("⚙️ 동시 처리: %d종목 | 벌크저장 단위: %d개 | API 세마포어: %dreq",
|
||||||
BATCH_CONCURRENCY, BULK_SAVE_CHUNK, settings.API_MAX_CONCURRENT_REQUESTS)
|
BATCH_CONCURRENCY, BULK_SAVE_CHUNK, settings.API_MAX_CONCURRENT_REQUESTS)
|
||||||
|
|
||||||
|
# ML 스킵 집계 초기화 — 사이클 종료 시 사유별 1줄 요약을 남긴다.
|
||||||
|
# 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 로그가 0줄이었다. (2026-09-28)
|
||||||
|
reset_ml_skip_stats()
|
||||||
|
|
||||||
# Step 0: DB 데이터 사전 확인 (collect_krx_data.py 미실행 감지)
|
# Step 0: DB 데이터 사전 확인 (collect_krx_data.py 미실행 감지)
|
||||||
async with AsyncSessionLocal() as _db:
|
async with AsyncSessionLocal() as _db:
|
||||||
_db_check = await _db.execute(
|
_db_check = await _db.execute(
|
||||||
@@ -629,6 +636,7 @@ async def batch_analyze_all_stocks(limit: int = None):
|
|||||||
logger.info(" ├─ 가격필터: %d건", price_flt)
|
logger.info(" ├─ 가격필터: %d건", price_flt)
|
||||||
logger.info(" ├─ 오류: %d건", err_count)
|
logger.info(" ├─ 오류: %d건", err_count)
|
||||||
logger.info("🔍 DB 스캐너 신호: %d개", len(scanner_tickers))
|
logger.info("🔍 DB 스캐너 신호: %d개", len(scanner_tickers))
|
||||||
|
log_ml_skip_summary("일일배치")
|
||||||
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
|
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
|||||||
114
backend/tests/test_ml_feature_direction.py
Normal file
114
backend/tests/test_ml_feature_direction.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""ML 피처 파이프라인 회귀 테스트 (2026-09-28).
|
||||||
|
|
||||||
|
두 결함을 behavioral 하게 고정한다.
|
||||||
|
|
||||||
|
1. **시계열 방향**: market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데
|
||||||
|
chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집었다.
|
||||||
|
→ ML 추론이 학습(trade_date ASC)과 정반대 방향의 시계열을 봤다.
|
||||||
|
2. **시퀀스 워밍업 NaN**: 탭뷸러만 NaN 검사를 받고 LSTM 시퀀스는 검사되지 않아
|
||||||
|
61~79봉 구간이 `int(nan)` → ValueError 크래시 밴드였다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.feature_engineering import (
|
||||||
|
LSTM_SEQUENCE_LENGTH,
|
||||||
|
MIN_BARS_FOR_PREDICT,
|
||||||
|
SEQUENCE_FEATURE_WARMUP_BARS,
|
||||||
|
chart_data_to_dataframe_v2,
|
||||||
|
extract_single_features_v2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_chart(n: int, oldest_first: bool = True) -> list:
|
||||||
|
"""상승 추세 합성 일봉. stck_bsop_date 는 영업일 근사(연속 날짜)."""
|
||||||
|
rows = []
|
||||||
|
for i in range(n):
|
||||||
|
base = 50_000 + i * 100
|
||||||
|
rows.append({
|
||||||
|
"stck_bsop_date": f"2026{(1 + i // 28):02d}{(1 + i % 28):02d}",
|
||||||
|
"stck_oprc": str(base),
|
||||||
|
"stck_hgpr": str(base + 300),
|
||||||
|
"stck_lwpr": str(base - 300),
|
||||||
|
"stck_clpr": str(base + 100),
|
||||||
|
"acml_vol": str(1_000_000 + i * 1_000),
|
||||||
|
})
|
||||||
|
return rows if oldest_first else list(reversed(rows))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 1. 방향 무관성 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_dataframe_v2_is_input_order_agnostic():
|
||||||
|
"""oldest-first / newest-first 어느 쪽을 넣어도 동일한 시간순 DataFrame."""
|
||||||
|
df_old = chart_data_to_dataframe_v2(_make_chart(120, oldest_first=True))
|
||||||
|
df_new = chart_data_to_dataframe_v2(_make_chart(120, oldest_first=False))
|
||||||
|
|
||||||
|
assert list(df_old["date"]) == list(df_new["date"])
|
||||||
|
# 시간 오름차순이어야 한다
|
||||||
|
dates = [str(d) for d in df_old["date"]]
|
||||||
|
assert dates == sorted(dates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_identical_regardless_of_input_order():
|
||||||
|
"""방향이 뒤집힌 입력에서도 피처가 같아야 한다 (구버전은 달랐다)."""
|
||||||
|
tab_a, seq_a, err_a = extract_single_features_v2(_make_chart(150, True))
|
||||||
|
tab_b, seq_b, err_b = extract_single_features_v2(_make_chart(150, False))
|
||||||
|
|
||||||
|
assert err_a is None and err_b is None, (err_a, err_b)
|
||||||
|
np.testing.assert_allclose(tab_a, tab_b, rtol=0, atol=0)
|
||||||
|
np.testing.assert_allclose(seq_a, seq_b, rtol=0, atol=0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_uptrend_is_not_read_as_downtrend():
|
||||||
|
"""상승 추세 입력이 실제로 상승으로 읽혀야 한다.
|
||||||
|
|
||||||
|
구버전은 oldest-first 입력을 뒤집어 상승장을 하락장으로 계산했다.
|
||||||
|
change_rate_1d 부호로 방향을 고정한다.
|
||||||
|
"""
|
||||||
|
from app.services.feature_engineering import FEATURE_COLUMNS
|
||||||
|
|
||||||
|
tab, _, err = extract_single_features_v2(_make_chart(150, oldest_first=True))
|
||||||
|
assert err is None, err
|
||||||
|
idx = FEATURE_COLUMNS.index("change_rate_1d")
|
||||||
|
assert tab[idx] > 0, f"상승 추세인데 change_rate_1d={tab[idx]} (방향 역전 의심)"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 2. 시퀀스 NaN 밴드 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_min_bars_covers_sequence_warmup():
|
||||||
|
"""최소 봉수는 시퀀스 길이 + 시퀀스 피처 워밍업을 모두 덮어야 한다."""
|
||||||
|
assert MIN_BARS_FOR_PREDICT >= LSTM_SEQUENCE_LENGTH + SEQUENCE_FEATURE_WARMUP_BARS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("n", [0, 1, 30, 60, 61, 70, 79])
|
||||||
|
def test_insufficient_bars_return_reason_not_nan(n):
|
||||||
|
"""봉수 부족은 NaN 을 흘리지 않고 reason 을 반환한다 (구 61~79 크래시 밴드)."""
|
||||||
|
tab, seq, err = extract_single_features_v2(_make_chart(n))
|
||||||
|
assert tab is None and seq is None
|
||||||
|
assert err, f"n={n} 에서 사유 없이 실패"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("n", [80, 81, 120, 200])
|
||||||
|
def test_sufficient_bars_produce_finite_features(n):
|
||||||
|
"""최소 봉수 이상에서는 탭뷸러·시퀀스 모두 유한해야 한다."""
|
||||||
|
tab, seq, err = extract_single_features_v2(_make_chart(n))
|
||||||
|
assert err is None, f"n={n}: {err}"
|
||||||
|
assert np.all(np.isfinite(tab)), "탭뷸러에 NaN/Inf"
|
||||||
|
assert np.all(np.isfinite(seq)), "LSTM 시퀀스에 NaN/Inf"
|
||||||
|
assert seq.shape[0] == LSTM_SEQUENCE_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 3. predict() 무예외 계약 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, [], [{}], [1, 2, 3], "not-a-list"])
|
||||||
|
def test_predict_never_raises_on_bad_input(bad):
|
||||||
|
"""predict() 는 어떤 입력에도 예외를 던지지 않고 dict 를 반환한다.
|
||||||
|
|
||||||
|
구버전은 NaN 이 섞이면 `int(nan)` → ValueError 로 호출부를 터뜨렸고,
|
||||||
|
market_analyzer 의 `except Exception: pass` 가 그것을 조용히 삼켰다.
|
||||||
|
"""
|
||||||
|
from app.services.ml_ensemble import MLEnsemble
|
||||||
|
|
||||||
|
result = MLEnsemble().predict(bad)
|
||||||
|
assert isinstance(result, dict)
|
||||||
@@ -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` |
|
| 08:50 | `batch_daily_analysis.py` | `logs/batch_analysis.log` |
|
||||||
| 09:00~15:00 매 10분 | `batch_intraday_analysis.py` | `logs/batch_intraday.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건인 것은 **정상**이며 장애가 아니다.
|
||||||
|
휴장일에 분석이 도는 것을 막고 싶으면 휴장일 판정을 추가해야 한다.
|
||||||
|
|
||||||
## 수동 실행
|
## 수동 실행
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ interface PerformanceData {
|
|||||||
summary: PerformanceSummary
|
summary: PerformanceSummary
|
||||||
by_strategy: Record<string, { total: number; win_rate: number; avg_return: number }>
|
by_strategy: Record<string, { total: number; win_rate: number; avg_return: number }>
|
||||||
by_exit_type: Record<string, { count: number; avg_return: number; win_rate: number }>
|
by_exit_type: Record<string, { count: number; avg_return: number; win_rate: number }>
|
||||||
worst_stocks: { stock_code: string; net_pnl: number }[]
|
worst_stocks: { stock_code: string; stock_name?: string; net_pnl: number; trade_count?: number }[]
|
||||||
best_stocks: { stock_code: string; net_pnl: number }[]
|
best_stocks: { stock_code: string; stock_name?: string; net_pnl: number; trade_count?: number }[]
|
||||||
period_days: number
|
period_days: number
|
||||||
total_trades: number
|
total_trades: number
|
||||||
generated_at: string
|
generated_at: string
|
||||||
@@ -464,9 +464,13 @@ export default function Analysis() {
|
|||||||
<h2 className="text-base font-semibold text-red-400 mb-3">최대 손실 종목 Top5</h2>
|
<h2 className="text-base font-semibold text-red-400 mb-3">최대 손실 종목 Top5</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{perf.worst_stocks.map((s) => (
|
{perf.worst_stocks.map((s) => (
|
||||||
<div key={s.stock_code} className="flex justify-between text-sm">
|
<div key={s.stock_code} className="flex justify-between items-baseline gap-2 text-sm">
|
||||||
<span className="text-gray-200">{s.stock_code}</span>
|
<span className="text-gray-200 truncate">
|
||||||
<span className={s.net_pnl < 0 ? "text-red-400" : "text-green-400"}>{fmtKRW(s.net_pnl)}</span>
|
{s.stock_name && s.stock_name !== s.stock_code ? s.stock_name : "종목명 미확인"}
|
||||||
|
<span className="ml-1.5 text-xs text-gray-500 font-mono">{s.stock_code}</span>
|
||||||
|
{s.trade_count ? <span className="ml-1.5 text-xs text-gray-500">{s.trade_count}건</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 ${s.net_pnl < 0 ? "text-red-400" : "text-green-400"}`}>{fmtKRW(s.net_pnl)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -475,9 +479,13 @@ export default function Analysis() {
|
|||||||
<h2 className="text-base font-semibold text-green-400 mb-3">최대 수익 종목 Top5</h2>
|
<h2 className="text-base font-semibold text-green-400 mb-3">최대 수익 종목 Top5</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{perf.best_stocks.map((s) => (
|
{perf.best_stocks.map((s) => (
|
||||||
<div key={s.stock_code} className="flex justify-between text-sm">
|
<div key={s.stock_code} className="flex justify-between items-baseline gap-2 text-sm">
|
||||||
<span className="text-gray-200">{s.stock_code}</span>
|
<span className="text-gray-200 truncate">
|
||||||
<span className={s.net_pnl > 0 ? "text-green-400" : "text-red-400"}>{fmtKRW(s.net_pnl)}</span>
|
{s.stock_name && s.stock_name !== s.stock_code ? s.stock_name : "종목명 미확인"}
|
||||||
|
<span className="ml-1.5 text-xs text-gray-500 font-mono">{s.stock_code}</span>
|
||||||
|
{s.trade_count ? <span className="ml-1.5 text-xs text-gray-500">{s.trade_count}건</span> : null}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 ${s.net_pnl > 0 ? "text-green-400" : "text-red-400"}`}>{fmtKRW(s.net_pnl)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user