하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
12 KiB
Python
258 lines
12 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",
|
||
"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)
|