"""포지션 사이징 코어 (순수 계산 — 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", "remaining_position_room", "remaining_pct_after_partial_sell", ] # ── 비율 → 금액 ────────────────────────────────────────────────────────────── 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) # ── 종목당 누적 한도 ───────────────────────────────────────────────────────── def remaining_position_room( total_assets: float, position_cap_ratio: float, existing_position_value: float, ) -> float: """종목당 **누적** 잔여 한도 = `max(0, 총자산 × 종목당상한 − 해당 종목 기존 보유 평가액)`. `clamp_by_ratios(..., [MAX_POSITION_SIZE], ...)` 는 **주문 1건**을 상한으로 묶을 뿐이라 같은 종목이 매일 추천되면 추가매수가 쌓여 종목 비중이 상한을 넘을 수 있다 (CLAUDE.md §5.1 "종목당 4% 절대 상한"). 이 함수는 기존 보유분을 차감한 잔여 한도를 돌려준다. **floor 있음** — 이미 한도를 초과했으면 0. `total_assets <= 0` 또는 `position_cap_ratio <= 0` 이면 한도를 계산할 수 없으므로 0 을 반환한다 (호출부가 "검증 불가"를 별도로 판단한다). 음수 보유 평가액은 0 으로 본다. """ if total_assets <= 0 or position_cap_ratio <= 0: return 0.0 existing = max(0.0, float(existing_position_value or 0.0)) return max(0.0, total_assets * position_cap_ratio - existing) # ── 분할매도 단계 기록 ─────────────────────────────────────────────────────── def remaining_pct_after_partial_sell( prev_remaining_pct: float, sell_pct: float, remaining_qty: int, original_qty: int, ) -> float: """분할매도 후 기록할 `remaining_pct` (원본 수량 대비, 소수 2자리). `remaining_pct` 게이트(평균회귀 `> 0.50`, 모멘텀 `> 0.75` / `0.45 < x <= 0.75`)는 "단계"를 판정한다. 실제 매도 주수는 과매도 방지를 위해 `int(원본 × sell_pct)` 로 **내림**하므로 홀수 수량에서는 실제 잔량 비율이 단계 값보다 크게 남는다 (예: 7주 50% → 3주 매도, 잔량 4/7=0.571 > 0.50). 잔량 비율만 기록하면 같은 단계가 쿨다운 후 재발동해 7/11/21주는 1주만 남고 5주는 전량이 팔렸다. 따라서 **단계 값 `prev − sell_pct` 를 상한**으로 기록한다. 실제 잔량 비율이 더 작으면 (외부 감소 등) 그쪽을 따른다 — 기록값은 항상 `min(단계값, 실제비율)`. DB 컬럼이 `Numeric(5,2)` 이므로 2자리로 반올림한다(0.7−0.3 의 부동소수 오차 흡수). 반올림은 상한 이후에 하므로 단계값을 넘어 게이트를 다시 여는 일은 없다 (단계값 자체가 2자리 값이고, 그보다 작은 값의 반올림은 단계값 이하). """ stage = float(prev_remaining_pct) - float(sell_pct) if original_qty and original_qty > 0: actual = max(0, int(remaining_qty)) / float(original_qty) value = min(stage, actual) else: value = stage return round(max(0.0, min(1.0, value)), 2)