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>
This commit is contained in:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -0,0 +1,354 @@
"""공용 매수 가드 (수동 매수 경로용) — 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,
)