Files
auto-stock/backend/app/services/buy_guards.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

355 lines
15 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.
"""공용 매수 가드 (수동 매수 경로용) — 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,
)