Files
auto-stock/backend/app/services/sizing_core.py
shuim 45677c8fbb fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor
- 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room)
- 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning
- 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed
- 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지)
- 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡

korea_investment_api
- get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld
  (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning

daytrading
- 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제)
- 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler)
- legacy _record_trade strategy_type=daytrading 명시

db_worker
- update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지)

alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함)
CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014

998 passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:37:29 +09:00

226 lines
10 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",
"remaining_position_room",
]
# ── 비율 → 금액 ──────────────────────────────────────────────────────────────
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)