Files
auto-stock/backend/app/services/sizing_core.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

201 lines
9.0 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.
"""포지션 사이징 코어 (순수 계산 — I/O·DB·네트워크·설정 의존성 없음)
CLAUDE.md §8.5-2 "포지션 사이징 4벌 중복" 통합용 단일 출처(single source of truth).
### 설계 원칙
1. **순수 함수.** `settings` / DB / 네트워크 / `await` 를 참조하지 않는다.
비율·상한·기준값은 모두 **인자로 받는다**(호출부가 `settings` 에서 읽어 전달).
2. **금액 동일성 보존이 목적.** 이 모듈은 통합 전 4벌 구현의 반환 금액을
부동소수점 단위로 재현한다. "더 나은 사이징"으로의 개선은 백테스트를 동반한
별도 작업이다(CLAUDE.md §8.1-4: 백테스트 없이 실전 사이징 변경 금지).
3. **억지 통합 금지.** 전략 고유 로직(Kelly 통계, RSI/ADX 가중, regime 분기,
적응형 cap, 글로벌 자본 가드, 스윙 자본 캡, 패턴 메모리)은 각 전략 파일에 남기고
이 모듈은 **공통 원시 연산**만 제공한다.
4. **`floor` 유무를 함수로 구분한다.** `max(0.0, ...)` 가 있는 구현과 없는 구현이
섞여 있었으므로 `budget_by_ratios`(floor 있음) / `clamp_by_ratios`(floor 없음)로
분리해 기존 동작을 그대로 보존한다.
### 대응 관계 (통합 전 구현 → 이 모듈)
| 통합 전 | 이 모듈 |
|---------|---------|
| `mean_reversion_strategy.calculate_position_size` 3중 min | `budget_by_ratios` |
| `mean_reversion_strategy.calculate_position_size_v2` 배율 후 재클램프 | `clamp_by_ratios` |
| `day_trading_strategy.calculate_day_trade_position_size` 3중 min | `budget_by_ratios` |
| `us_stock_strategy.calculate_us_position_size` 3중 min | `budget_by_ratios` |
| `trade_executor._calculate_kelly_position` Kelly 수식 | `half_kelly_fraction` + `clamp_by_ratios` |
| `trade_executor` 투자금액→주수 (Decimal) | `amount_to_quantity` |
| `trade_executor` 소액계좌 최소주문 하향 | `effective_min_buy_amount` |
| `daytrading` confidence 스케일 `min(1.2, conf/0.7)` | `apply_confidence_scale` |
### 한도 **숫자**는 이 모듈에 없다
4% / 6% / 5% / 30% / 70% / 95% / 15만원 등은 전부 호출부(`settings` 또는 모듈 상수)가
소유한다. 이 모듈은 비율을 해석만 한다.
"""
from decimal import Decimal
from typing import List, Optional, Sequence
__all__ = [
"cap_by_ratio",
"remaining_strategy_room",
"budget_by_ratios",
"clamp_by_ratios",
"apply_confidence_scale",
"amount_to_quantity",
"half_kelly_fraction",
"effective_min_buy_amount",
]
# ── 비율 → 금액 ──────────────────────────────────────────────────────────────
def cap_by_ratio(total_assets: float, ratio: float) -> float:
"""총자산 × 비율. 음수 total_assets 를 보정하지 않는다(상위 min/floor 가 처리)."""
return total_assets * ratio
def remaining_strategy_room(
total_assets: float,
strategy_ratio: float,
current_allocation: float,
) -> float:
"""전략(스윙/DT/미국) 잔여 예산 = 총자산×비율 − 현재 해당 전략 배분액.
**floor 없음** — 한도를 이미 초과했으면 음수를 반환한다. 통합 전 구현과 동일하며,
음수 처리는 `budget_by_ratios` 의 `max(0.0, ...)` 이 담당한다.
"""
return total_assets * strategy_ratio - current_allocation
def budget_by_ratios(
total_assets: float,
available_cash: float,
current_allocation: float,
max_per_position: float,
max_strategy_ratio: float,
) -> float:
"""3중 한도 교집합 = `max(0, min(종목당 한도, 전략 잔여, 가용현금))`.
통합 전 4벌 중 3벌(국내 스윙 / 데이트레이딩 / 미국)이 **완전히 동일한** 이 식을
각자 복제하고 있었다.
Args:
total_assets: 총자산(통화 단위는 호출부 기준. 미국은 KRW 환산값).
available_cash: 가용 현금.
current_allocation: 해당 전략에 이미 배분된 금액.
max_per_position: 종목당 최대 비중(예: 0.04).
max_strategy_ratio: 전략 전체 최대 비중(예: BULL 0.70 / DT 0.30).
Returns:
매수 가능 금액. 0 이면 매수 불가.
"""
max_by_position = cap_by_ratio(total_assets, max_per_position)
remaining = remaining_strategy_room(total_assets, max_strategy_ratio, current_allocation)
return max(0.0, min(max_by_position, remaining, available_cash))
def clamp_by_ratios(
amount: float,
total_assets: float,
ratios: Sequence[float],
available_cash: float,
) -> float:
"""배율 적용 **후** 재클램프 = `min(amount, total×r₁, …, available_cash)`.
**floor(`max(0, …)`) 없음** — 통합 전 `calculate_position_size_v2` 마지막 줄
(`min(base*mult, max_allowed, available_cash)`) 및 Kelly 반환식과 동일하게 유지한다.
`ratios` 가 비면 `min(amount, available_cash)`.
"""
caps = [amount, available_cash]
caps.extend(cap_by_ratio(total_assets, r) for r in ratios)
return min(caps)
# ── 신뢰도 스케일 ────────────────────────────────────────────────────────────
def apply_confidence_scale(
amount: float,
confidence: float,
base: float = 0.7,
cap: float = 1.2,
) -> float:
"""신뢰도 비례 조정 = `amount × min(cap, confidence / base)`.
데이트레이딩 진입부(`daytrading/entry_service`, `api/endpoints/day_trading`)가 쓰는
식과 동일하다(0.6→86%, 0.7→100%, 0.85→121%). 증액 시에는 **호출부에서** 반드시
`clamp_by_ratios` 로 재클램프해야 한다(한도 초과 방지).
주의: 이 함수만으로는 한도를 보장하지 않는다. `base <= 0` 이면 조정하지 않는다.
"""
if base <= 0:
return amount
return amount * min(cap, confidence / base)
# ── 금액 → 주수 ──────────────────────────────────────────────────────────────
def amount_to_quantity(amount: float, price: float) -> int:
"""예산 → 주수 (내림). `Decimal(str(...))` 경로로 부동소수점 오차를 피한다.
규약:
- `price <= 0` → 0 (0 나눗셈 예외 대신 0). 호출부에 이미 가격 유효성 가드가 있다.
- `amount <= 0` → 0. **`max(1, …)` 를 쓰지 않는다** — 예산 0에 1주를 매수하는
버그였다. (반면 "보유수량 × 분할비율"에서의 `max(1, …)` 는 정당하므로
이 함수의 대상이 아니다.)
"""
if price <= 0 or amount <= 0:
return 0
return int(Decimal(str(amount)) / Decimal(str(price)))
# ── Kelly (통계 부분만 — DB 조회는 호출부) ──────────────────────────────────
def half_kelly_fraction(
profit_loss_rates: List[float],
min_trades: int = 5,
) -> Optional[float]:
"""Half-Kelly 비중 f*/2 계산 (순수 통계). DB 조회는 호출부가 담당한다.
f* = (p·b − (1−p)) / b, p=승률, b=평균이익/평균손실(Payoff Ratio)
Returns:
`max(0.0, f*/2)`. 아래 경우는 **None**(호출부는 0.0 반환 후 fallback):
- 거래 수 < `min_trades`
- 전승(손실 0건) 또는 전패(이익 0건) → b 계산 불가
"""
rates = [float(r) for r in profit_loss_rates]
if len(rates) < min_trades:
return None
wins = [r for r in rates if r > 0]
losses = [abs(r) for r in rates if r < 0]
if not wins or not losses:
return None
p = len(wins) / len(rates)
b = (sum(wins) / len(wins)) / (sum(losses) / len(losses))
kelly_f = (p * b - (1 - p)) / b
return max(0.0, kelly_f / 2)
# ── 최소 주문금액 ────────────────────────────────────────────────────────────
def effective_min_buy_amount(
config_min_amount: float,
total_assets: float,
position_cap_ratio: float,
factor: float = 0.9,
) -> float:
"""실효 최소 주문금액 = `min(config값, 총자산 × 종목당상한 × factor)`.
**의도된 설계(변경 금지)**: 총자산이 작으면 종목당 예산(총자산×4%) 자체가
config 최소액(15만원) 미만이 되어 모든 종목이 영구 차단된다
(2026-09-23 하루 141건 차단 사례). 큰 계좌에서는 항상 config값이 더 작게 선택되므로
문서(§5.1 "15만원")와 동일하게 동작한다.
`total_assets <= 0` 또는 `position_cap_ratio <= 0` 이면 config값을 그대로 반환.
"""
if total_assets <= 0 or position_cap_ratio <= 0:
return config_min_amount
small_account_cap = total_assets * position_cap_ratio * factor
if small_account_cap <= 0:
return config_min_amount
return min(config_min_amount, small_account_cap)