## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 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>
201 lines
9.0 KiB
Python
201 lines
9.0 KiB
Python
"""포지션 사이징 코어 (순수 계산 — 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)
|