Files
auto-stock/backend/app/services/analysis_db_service.py
shuim 1b9257c4d2 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>
2026-09-28 08:58:18 +09:00

242 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""StockAnalysis DB 서비스 — 단일 진실 공급원 (Single Source of Truth)
StockAnalysis ORM 저장 로직을 중앙화하여 3곳의 중복 제거:
- batch_daily_analysis.py (bulk)
- batch_intraday_analysis.py (per-stock, rescore)
PostgreSQL UPSERT(ON CONFLICT DO UPDATE)를 사용하여
DELETE → INSERT 사이의 race condition 을 원자적으로 해결.
워커 패턴 (CLAUDE.md 3.2):
- session 을 직접 받는 `upsert_analysis` / `bulk_upsert_analysis` 는
scheduler / batch 에서 기존처럼 사용 가능 (시그니처 불변).
- 실시간 콜백·인트라데이 분석에서는 `queue_upsert_analysis` /
`queue_bulk_upsert_analysis` 를 사용하여 DB 락 경합을 방지.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
from app.models.models import StockAnalysis
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.api.endpoints.recommendations import StockRecommendation
# ─────────────────────────────────────────────────────────────────────────────
# 헬퍼
# ─────────────────────────────────────────────────────────────────────────────
def passes_price_filter(result: "StockRecommendation") -> bool:
"""5,000원 이하 저가주 필터 (CLAUDE.md 5.4).
True → 통과, False → 필터됨.
"""
return not (result.current_price and result.current_price < 5000)
# UPSERT 시 갱신할 컬럼 집합 (PK·unique key·최초등록 시각 제외)
_UPSERT_UPDATE_COLS = frozenset({
"stock_name", "current_price", "change_rate", "volume",
"rsi", "ma5", "ma20", "ma60",
"stochastic_k", "stochastic_d",
"bollinger_upper", "bollinger_lower", "bollinger_middle",
"signals", "score",
"ml_score", "ml_confidence", "ml_signal",
"atr_14", "adx_14",
"updated_at",
})
def to_analysis_dict(
stock_code: str,
stock_name: str,
result: "StockRecommendation",
analysis_date: date,
) -> dict:
"""StockRecommendation → StockAnalysis INSERT dict 변환.
ORM 객체 대신 dict 를 반환하여 단건 UPSERT 와 bulk INSERT 에서 모두 재사용.
"""
return {
"stock_code": stock_code,
"stock_name": stock_name,
"analysis_date": analysis_date,
"current_price": result.current_price,
"change_rate": result.change_rate,
"volume": result.volume,
"rsi": result.rsi,
"ma5": result.ma5,
"ma20": result.ma20,
"ma60": result.ma60,
"stochastic_k": result.stochastic_k,
"stochastic_d": result.stochastic_d,
"bollinger_upper": result.bollinger_upper,
"bollinger_lower": result.bollinger_lower,
"bollinger_middle": result.bollinger_middle,
"signals": result.signals,
"score": result.score,
"ml_score": result.ml_score,
"ml_confidence": result.ml_confidence,
"ml_signal": result.ml_signal,
"atr_14": result.atr_14,
"adx_14": result.adx_14,
"updated_at": datetime.now(timezone.utc).replace(tzinfo=None),
}
# ─────────────────────────────────────────────────────────────────────────────
# 내부 실행 함수 (db_worker.py 에서도 호출)
# ─────────────────────────────────────────────────────────────────────────────
async def _execute_upsert(session: AsyncSession, rows: list[dict]) -> None:
"""실제 PostgreSQL UPSERT 실행 — session 은 호출 측이 관리."""
if not rows:
return
stmt = pg_insert(StockAnalysis).values(rows)
stmt = stmt.on_conflict_do_update(
constraint="uq_stock_analysis_code_date",
set_={col: stmt.excluded[col] for col in _UPSERT_UPDATE_COLS},
)
await session.execute(stmt)
# ─────────────────────────────────────────────────────────────────────────────
# 기존 session 기반 함수 — 시그니처 불변 (CLAUDE.md 4.1)
# ─────────────────────────────────────────────────────────────────────────────
async def upsert_analysis(
session: AsyncSession,
stock_code: str,
stock_name: str,
result: "StockRecommendation",
analysis_date: date,
) -> None:
"""단건 UPSERT — session 직접 사용. 배치 스케줄러에서 그대로 호출 가능."""
row = to_analysis_dict(stock_code, stock_name, result, analysis_date)
await _execute_upsert(session, [row])
async def bulk_upsert_analysis(
session: AsyncSession,
items: list[tuple[str, str, "StockRecommendation"]],
analysis_date: date,
) -> int:
"""다건 UPSERT — session 직접 사용. 일봉 배치에서 그대로 호출 가능."""
if not items:
return 0
rows = [to_analysis_dict(code, name, r, analysis_date) for code, name, r in items]
await _execute_upsert(session, rows)
return len(rows)
# ─────────────────────────────────────────────────────────────────────────────
# NEW: 워커 큐 기반 함수 — 실시간 콜백·인트라데이에서 사용 (CLAUDE.md 3.2)
# ─────────────────────────────────────────────────────────────────────────────
async def queue_upsert_analysis(
stock_code: str,
stock_name: str,
result: "StockRecommendation",
analysis_date: date,
) -> None:
"""단건 UPSERT 를 db_worker 큐에 위임.
실시간 스레드 / 인트라데이 분석에서 사용.
DB 세션을 직접 열지 않으므로 락 경합 없음.
"""
from app.services.db_worker import enqueue_upsert_analysis
row = to_analysis_dict(stock_code, stock_name, result, analysis_date)
await enqueue_upsert_analysis(row)
async def queue_bulk_upsert_analysis(
items: list[tuple[str, str, "StockRecommendation"]],
analysis_date: date,
) -> int:
"""다건 UPSERT 를 db_worker 큐에 위임.
배치 분석 결과를 비동기로 저장할 때 사용.
API 응답 지연 없이 큐에 넣고 즉시 반환.
Returns: 큐에 등록된 row 수
"""
if not items:
return 0
from app.services.db_worker import enqueue_bulk_upsert_analysis
rows = [to_analysis_dict(code, name, r, analysis_date) for code, name, r in items]
await enqueue_bulk_upsert_analysis(rows)
return len(rows)
# ─────────────────────────────────────────────────────────────────────────────
# DB 스캐너 — 저장된 분석 결과로 신호 종목 필터링 (API 호출 없음)
# ─────────────────────────────────────────────────────────────────────────────
async def run_db_scanner(analysis_date: date) -> set[str]:
"""배치 DB 기반 코스닥·코스피 스캐너 (추가 API 호출 없음).
조건:
- RSI(14) ≤ 30 (과매도)
- 종가 ≤ BB 하단 × 1.015
- 시세 ≥ 5,000원
- ADX ≤ 30 (강한 추세 = 평균회귀 부적합)
- MA5 ≥ MA20 × 0.95 (강한 하락 추세 제외)
Returns: 신호 종목 코드 set
"""
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(
StockAnalysis.stock_code,
StockAnalysis.current_price,
StockAnalysis.rsi,
StockAnalysis.bollinger_lower,
StockAnalysis.adx_14,
StockAnalysis.ma5,
StockAnalysis.ma20,
).where(
StockAnalysis.analysis_date == analysis_date,
StockAnalysis.current_price >= 5000,
StockAnalysis.rsi.isnot(None),
StockAnalysis.rsi <= 30,
StockAnalysis.bollinger_lower.isnot(None),
)
)
rows = result.all()
signal_tickers: set[str] = set()
for row in rows:
price, bb_lower = row.current_price, row.bollinger_lower
if not bb_lower or bb_lower == 0:
continue
pct = (price - bb_lower) / bb_lower * 100
if pct > 1.5:
continue
adx = row.adx_14
if adx is not None and adx > 30:
continue
ma5, ma20 = row.ma5, row.ma20
if ma5 is not None and ma20 is not None and ma20 > 0:
if float(ma5) < float(ma20) * 0.95:
continue
signal_tickers.add(row.stock_code)
logger.info(
"🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30 + MA방향)",
len(signal_tickers),
)
return signal_tickers
except Exception as exc:
logger.error("❌ DB 스캐너 실패: %s", exc, exc_info=True)
return set()