Files
auto-stock/backend/app/services/sizing_core.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 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>
2026-10-02 12:01:31 +09:00

258 lines
12 KiB
Python
Raw Permalink 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",
"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)