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