하네스 규칙 검사 전역 위반 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>
355 lines
15 KiB
Python
355 lines
15 KiB
Python
"""공용 매수 가드 (수동 매수 경로용) — CLAUDE.md §5.1.
|
||
|
||
[H-4 2026-10-02] 수동 매수(`manual_buy.py` / `portfolio_v2.py`)는 가격 필터·밴·MDD·
|
||
보유수만 확인하고 아래를 모두 빠뜨리고 있었다.
|
||
|
||
- 종목당 4% **누적** 상한(기존 보유 평가액 차감) — 1건 상한만 있었고, `quantity` 를
|
||
지정하면 그마저 없었다
|
||
- 현금 유보(`MAX_TOTAL_CAPITAL_USAGE`, 95% 사용 상한)
|
||
- 레버리지/인버스 ETF 20%(`LEVERAGE_ETF_MAX_RATIO`)
|
||
- 최소 주문금액 실효값(`effective_min_buy_amount`)
|
||
- 적응형 Crisis 모드(신규 진입 전면 차단) / 적응형 포지션 상한(`min(adp, 4%)`)
|
||
- 섹터·상관관계 필터(`correlation_filter`)
|
||
- 현금 필드: `dnca_tot_amt`(D+2 예수금, 당일 매수 많으면 음수) 를 1순위로 썼다 —
|
||
`portfolio.py` 의 신뢰 순서상 최하위다
|
||
|
||
이 모듈은 위 가드를 **한 곳**에서 판정한다. 정의는 `trade_executor._execute_buy` 의
|
||
대응 단계와 같다(같은 sizing_core 원시 연산·같은 계수). trade_executor 는 다른 작업이
|
||
편집 중이라 수정하지 않았고, 이후 그쪽이 이 모듈을 쓰도록 이관하는 것을 권장한다.
|
||
|
||
규칙
|
||
- 가드 판정은 try 밖. I/O(잔고·매수가능금액·상관 필터)만 개별 try 로 감싸고,
|
||
실패하면 `BuyGuardRejected` 로 **매수 거부**(fail-closed)한다 — 검증할 수 없는
|
||
한도는 통과시키지 않는다.
|
||
- 지정 수량은 거부하지 않고 **클램프**(초과분 축소)하고 사유를 반환한다.
|
||
- 한도 숫자는 전부 settings 소유(하드코딩 없음). 청산 경로는 이 모듈을 호출하지 않는다.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Iterable, List, Optional, Tuple
|
||
|
||
from app.core.config import settings
|
||
from app.services.sizing_core import (
|
||
amount_to_quantity,
|
||
effective_min_buy_amount,
|
||
remaining_position_room,
|
||
)
|
||
from app.services.trade_helpers import get_leverage_check_keywords, is_leverage_etf
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# effective_min_buy_amount 의 소액계좌 완화 계수 — trade_executor._execute_buy /
|
||
# _apply_position_cap_room_v2 와 동일 값(정의 일치가 목적이므로 같은 상수를 쓴다).
|
||
_MIN_BUY_SMALL_ACCOUNT_FACTOR = 0.9
|
||
|
||
# 예수금 필드 신뢰 순서 — app/api/endpoints/portfolio.py `_cash_keys` 와 동일 순서
|
||
# (`_ord_psbl_cash` 는 매수가능조회 결과를 주입한 키라 여기서는 get_buyable_cash 로 대체).
|
||
_CASH_KEYS_BY_TRUST = (
|
||
"prvs_rcdl_excc_amt",
|
||
"nxdy_excc_amt",
|
||
"d2_depo_amt",
|
||
"d2_deposit",
|
||
"dnca_tot_amt",
|
||
"nass_amt",
|
||
)
|
||
|
||
|
||
class BuyGuardRejected(Exception):
|
||
"""매수 거부. 엔드포인트가 HTTPException(status_code, detail) 로 변환한다."""
|
||
|
||
def __init__(self, status_code: int, reason: str, detail: str):
|
||
super().__init__(detail)
|
||
self.status_code = status_code
|
||
self.reason = reason
|
||
self.detail = detail
|
||
|
||
|
||
@dataclass
|
||
class BuyGuardDecision:
|
||
quantity: int
|
||
requested_quantity: Optional[int]
|
||
budget: float
|
||
available_cash: float
|
||
total_assets: float
|
||
position_cap_ratio: float
|
||
min_buy_amount: float
|
||
clamped: bool = False
|
||
clamp_reasons: List[str] = field(default_factory=list)
|
||
|
||
def to_response(self) -> dict:
|
||
return {
|
||
"requested_quantity": self.requested_quantity,
|
||
"clamped": self.clamped,
|
||
"clamp_reasons": list(self.clamp_reasons),
|
||
"budget": round(self.budget),
|
||
"position_cap_ratio": self.position_cap_ratio,
|
||
}
|
||
|
||
|
||
# ── 순수 판정 (I/O 없음) ─────────────────────────────────────────────────────
|
||
|
||
def pick_available_cash_v2(output2: dict) -> float:
|
||
"""잔고 output2 에서 신뢰 순서대로 첫 양수 예수금을 고른다(없으면 0)."""
|
||
for k in _CASH_KEYS_BY_TRUST:
|
||
try:
|
||
v = float(str((output2 or {}).get(k) or 0).replace(",", ""))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if v > 0:
|
||
return v
|
||
return 0.0
|
||
|
||
|
||
async def resolve_available_cash_v2(kis: Any, output2: dict, stock_code: str) -> float:
|
||
"""주문가능 현금 단일 정의 (수동 매수·trade_executor 공용).
|
||
|
||
1순위 `kis.get_buyable_cash(stock_code)`(ord_psbl_cash, 당일 매도대금 포함),
|
||
0/실패면 잔고 output2 를 신뢰 순서(`_CASH_KEYS_BY_TRUST`, dnca_tot_amt 는 후순위)로.
|
||
"""
|
||
try:
|
||
ord_psbl = float(await kis.get_buyable_cash(stock_code) or 0)
|
||
except Exception as e:
|
||
logger.warning(f"[CASH] {stock_code}: 매수가능금액 조회 실패 — 잔고 예수금으로 대체: {e}")
|
||
ord_psbl = 0.0
|
||
return ord_psbl if ord_psbl > 0 else pick_available_cash_v2(output2 or {})
|
||
|
||
|
||
def existing_position_qty_v2(
|
||
holding: Any, stock_code: str, include_dt_inmemory: bool = True
|
||
) -> int:
|
||
"""기존 보유 수량 = max(Portfolio 행 수량, DT 인메모리 수량).
|
||
|
||
trade_executor._existing_position_qty_v2 와 같은 정의(합산하면 sync 이후 이중 계상).
|
||
멀티테넌트 사용자 컨텍스트는 DT 인메모리 상태가 레거시 계좌 소유라 제외한다.
|
||
"""
|
||
row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0
|
||
dt_qty = 0
|
||
if include_dt_inmemory:
|
||
try:
|
||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||
dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[BUY_GUARD] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {e}"
|
||
)
|
||
return max(row_qty, dt_qty)
|
||
|
||
|
||
def position_cap_room_v2(
|
||
total_assets: float,
|
||
position_cap_ratio: float,
|
||
existing_qty: int,
|
||
current_price: float,
|
||
) -> float:
|
||
"""종목당 **누적** 잔여 한도 = max(0, 총자산×cap − 기존 보유 평가액).
|
||
|
||
trade_executor._apply_position_cap_room_v2 와 같은 sizing_core 연산.
|
||
"""
|
||
existing_value = max(0, int(existing_qty)) * float(current_price)
|
||
return remaining_position_room(total_assets, position_cap_ratio, existing_value)
|
||
|
||
|
||
def leverage_room_v2(
|
||
stock_name: str, holdings: Iterable[Any], total_assets: float
|
||
) -> Optional[float]:
|
||
"""레버리지/인버스 ETF 잔여 한도. 대상 종목이 레버리지가 아니면 None(무제한).
|
||
|
||
**이번 주문 포함** 20%(`LEVERAGE_ETF_MAX_RATIO`) 를 넘지 않게 잔여 한도로 묶는다.
|
||
trade_executor(§7.7, `(보유 + invest_amount) / 총자산 > 한도` 이면 HOLD)와 같은 정의다 —
|
||
자동 경로는 초과 시 거부, 수동 경로는 지정 수량을 잔여 한도까지 클램프한다.
|
||
"""
|
||
kw = get_leverage_check_keywords()
|
||
if not is_leverage_etf(stock_name, kw):
|
||
return None
|
||
lev_total = 0.0
|
||
for h in holdings or ():
|
||
if is_leverage_etf(getattr(h, "stock_name", "") or "", kw):
|
||
px = float(getattr(h, "current_price", 0) or getattr(h, "average_price", 0) or 0)
|
||
lev_total += float(getattr(h, "quantity", 0) or 0) * px
|
||
return max(0.0, total_assets * settings.LEVERAGE_ETF_MAX_RATIO - lev_total)
|
||
|
||
|
||
def resolve_position_cap_ratio_v2() -> Tuple[float, Optional[str]]:
|
||
"""(종목당 상한 비율, 차단 사유). 적응형 상한은 MAX_POSITION_SIZE 로 클램프.
|
||
|
||
적응형 조회가 예외를 내면 Crisis 여부를 확인할 수 없으므로 거부한다(fail-closed).
|
||
"""
|
||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||
|
||
try:
|
||
adp = get_current_adaptive_params()
|
||
except Exception as e:
|
||
logger.warning(f"[BUY_GUARD] 적응형 파라미터 조회 실패 — 매수 거부: {e}")
|
||
return settings.MAX_POSITION_SIZE, "adaptive_unavailable"
|
||
cap = settings.MAX_POSITION_SIZE
|
||
if not getattr(adp, "fallback_used", True):
|
||
cap = min(float(adp.position_size), settings.MAX_POSITION_SIZE)
|
||
if adp.trading_mode == "crisis" and int(adp.max_daily_trades) <= 0:
|
||
return cap, "adaptive_crisis_mode"
|
||
return cap, None
|
||
|
||
|
||
def size_manual_buy_v2(
|
||
*,
|
||
stock_code: str,
|
||
order_price: float,
|
||
current_price: float,
|
||
requested_quantity: Optional[int],
|
||
total_assets: float,
|
||
available_cash: float,
|
||
position_cap_ratio: float,
|
||
existing_qty: int,
|
||
leverage_room: Optional[float],
|
||
) -> BuyGuardDecision:
|
||
"""한도 교집합으로 매수 가능 수량 산출 (순수 함수, try 없음).
|
||
|
||
예산 = min(
|
||
종목당 누적 잔여(총자산×cap − 기존 보유 평가액),
|
||
가용현금 − 현금 유보(총자산×(1−MAX_TOTAL_CAPITAL_USAGE)),
|
||
레버리지 잔여(해당 시),
|
||
)
|
||
지정 수량은 예산 수량으로 클램프한다. 최소주문금액 미만이면 거부.
|
||
"""
|
||
if total_assets <= 0:
|
||
raise BuyGuardRejected(502, "total_assets_unknown",
|
||
"총자산을 확인할 수 없어 종목당 상한을 검증할 수 없습니다 — 매수 거부")
|
||
if available_cash <= 0:
|
||
raise BuyGuardRejected(400, "no_cash", "주문가능 현금이 없습니다")
|
||
if order_price <= 0:
|
||
raise BuyGuardRejected(400, "invalid_price", "유효하지 않은 주문가")
|
||
|
||
min_buy = effective_min_buy_amount(
|
||
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
|
||
total_assets=total_assets,
|
||
position_cap_ratio=position_cap_ratio,
|
||
factor=_MIN_BUY_SMALL_ACCOUNT_FACTOR,
|
||
)
|
||
|
||
caps: List[Tuple[str, float]] = []
|
||
pos_room = position_cap_room_v2(total_assets, position_cap_ratio, existing_qty, current_price)
|
||
caps.append((
|
||
f"종목당 누적 상한 {position_cap_ratio:.1%} (기존 보유 {existing_qty}주 차감)", pos_room,
|
||
))
|
||
cash_reserve = total_assets * (1 - settings.MAX_TOTAL_CAPITAL_USAGE)
|
||
caps.append((
|
||
f"현금 유보 {1 - settings.MAX_TOTAL_CAPITAL_USAGE:.0%} (가용현금 {available_cash:,.0f}원)",
|
||
max(0.0, available_cash - cash_reserve),
|
||
))
|
||
if leverage_room is not None:
|
||
caps.append((f"레버리지 ETF 상한 {settings.LEVERAGE_ETF_MAX_RATIO:.0%}", leverage_room))
|
||
|
||
binding_label, budget = min(caps, key=lambda c: c[1])
|
||
budget = max(0.0, budget)
|
||
|
||
if budget < min_buy:
|
||
raise BuyGuardRejected(
|
||
400, "budget_below_min",
|
||
f"매수 가능 예산 {budget:,.0f}원 < 최소주문 {min_buy:,.0f}원 — 제약: {binding_label}",
|
||
)
|
||
|
||
max_qty = amount_to_quantity(budget, order_price)
|
||
decision = BuyGuardDecision(
|
||
quantity=max_qty,
|
||
requested_quantity=requested_quantity,
|
||
budget=budget,
|
||
available_cash=available_cash,
|
||
total_assets=total_assets,
|
||
position_cap_ratio=position_cap_ratio,
|
||
min_buy_amount=min_buy,
|
||
)
|
||
if requested_quantity is not None:
|
||
if requested_quantity > max_qty:
|
||
decision.clamped = True
|
||
decision.clamp_reasons.append(
|
||
f"요청 {requested_quantity}주 → {max_qty}주 축소 ({binding_label}, 예산 {budget:,.0f}원)"
|
||
)
|
||
logger.warning(
|
||
f"[BUY_GUARD] {stock_code}: 지정 수량 클램프 {requested_quantity} → {max_qty}주 "
|
||
f"— {binding_label}, 예산 {budget:,.0f}원"
|
||
)
|
||
else:
|
||
decision.quantity = int(requested_quantity)
|
||
|
||
if decision.quantity < 1:
|
||
raise BuyGuardRejected(
|
||
400, "quantity_zero",
|
||
f"예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 — 제약: {binding_label}",
|
||
)
|
||
if decision.quantity * order_price < min_buy:
|
||
raise BuyGuardRejected(
|
||
400, "min_amount_guard",
|
||
f"주문금액 {decision.quantity * order_price:,.0f}원 < 최소주문 {min_buy:,.0f}원",
|
||
)
|
||
return decision
|
||
|
||
|
||
# ── I/O 포함 진입점 ──────────────────────────────────────────────────────────
|
||
|
||
async def evaluate_manual_buy_v2(
|
||
*,
|
||
kis: Any,
|
||
stock_code: str,
|
||
stock_name: str,
|
||
current_price: float,
|
||
order_price: float,
|
||
requested_quantity: Optional[int],
|
||
holding: Any,
|
||
current_holdings: List[Any],
|
||
include_dt_inmemory: bool = True,
|
||
) -> BuyGuardDecision:
|
||
"""수동 매수 공용 가드. 통과하면 주문 수량을, 아니면 BuyGuardRejected 를 던진다.
|
||
|
||
호출 전제: 가격 필터·loss_tracker 밴·MDD·보유수·TRADING_HALTED 는 엔드포인트가
|
||
이미 확인했다(기존 동작 유지). 여기서는 사이징·자본 계열 가드를 판정한다.
|
||
"""
|
||
# 1) 적응형 Crisis / 종목당 상한
|
||
cap_ratio, cap_block = resolve_position_cap_ratio_v2()
|
||
if cap_block == "adaptive_crisis_mode":
|
||
raise BuyGuardRejected(423, cap_block, "적응형 Crisis 모드 — 신규 진입 전면 차단 상태")
|
||
if cap_block is not None:
|
||
raise BuyGuardRejected(503, cap_block, "적응형 파라미터를 확인할 수 없어 매수를 거부합니다")
|
||
|
||
# 2) 계좌 조회 (실패 → 거부)
|
||
try:
|
||
balance = await kis.get_balance()
|
||
output2 = (balance or {}).get("output2") or [{}]
|
||
b = output2[0] if isinstance(output2, list) else output2
|
||
b = b or {}
|
||
total_assets = float(str(b.get("tot_evlu_amt") or 0).replace(",", ""))
|
||
except Exception as e:
|
||
logger.warning(f"[BUY_GUARD] {stock_code}: 잔고 조회 실패 — 매수 거부: {e}")
|
||
raise BuyGuardRejected(502, "balance_unavailable", f"잔고 조회 실패: {e}")
|
||
available_cash = await resolve_available_cash_v2(kis, b, stock_code)
|
||
|
||
# 3) 섹터·상관관계 필터 (예외 → 거부)
|
||
try:
|
||
from app.services.correlation_filter import validate_correlation_filter
|
||
# 섹터당 최대 종목 수·상관계수 임계값은 validate_correlation_filter 의 기본값이
|
||
# 정본이다(config 에 키가 없고 trade_executor 도 같은 값으로 폴백한다).
|
||
corr_ok, corr_reason = await validate_correlation_filter(
|
||
stock_code=stock_code,
|
||
portfolio_holdings=current_holdings,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[BUY_GUARD] {stock_code}: 상관관계 필터 실패 — 매수 거부: {e}")
|
||
raise BuyGuardRejected(503, "correlation_unavailable", f"섹터/상관관계 필터 확인 실패: {e}")
|
||
if not corr_ok:
|
||
raise BuyGuardRejected(400, "correlation_filter", f"섹터/상관관계 필터: {corr_reason}")
|
||
|
||
# 4) 사이징 (순수 판정)
|
||
existing_qty = existing_position_qty_v2(holding, stock_code, include_dt_inmemory)
|
||
lev_room = leverage_room_v2(stock_name, current_holdings, total_assets)
|
||
return size_manual_buy_v2(
|
||
stock_code=stock_code,
|
||
order_price=order_price,
|
||
current_price=current_price,
|
||
requested_quantity=requested_quantity,
|
||
total_assets=total_assets,
|
||
available_cash=available_cash,
|
||
position_cap_ratio=cap_ratio,
|
||
existing_qty=existing_qty,
|
||
leverage_room=lev_room,
|
||
)
|