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

@@ -1,8 +1,10 @@
"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응).
POST /api/v1/portfolio/buy
- quantity 생략 시: 총자산 × MAX_POSITION_SIZE 예산으로 시장가 수량 자동 산출
- quantity 생략 시: 공용 매수 가드(services/buy_guards) 예산으로 수량 자동 산출
- quantity 지정 시: 같은 예산 상한으로 클램프(응답 clamped/clamp_reasons)
- 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED
+ buy_guards: 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·Crisis·섹터/상관
- 주문은 trade_executor.safe_place_order (종목별 세마포어 + Redis 분산락) 경유
- DB 쓰기는 db_worker 큐 경유(insert_trade_history / upsert_portfolio), 이후 KIS 잔고 재동기화
"""
@@ -26,6 +28,7 @@ from app.models.models import Portfolio
from app.services.korea_investment_api import korea_investment_api
from app.services.loss_tracker import loss_tracker
from app.services.mdd_manager import mdd_manager
from app.services.buy_guards import BuyGuardRejected, evaluate_manual_buy_v2
from app.services.utils import _is_domestic
logger = logging.getLogger(__name__)
@@ -105,25 +108,29 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
if cnt >= settings.MAX_HOLDINGS_COUNT:
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
# ── 수량 산출 (예산 = 총자산 × MAX_POSITION_SIZE, 예수금 한도) ────────────
quantity = request.quantity
# ── 사이징·자본 가드 (CLAUDE.md §5.1, 공용 buy_guards) ─────────────────
# [H-4 2026-10-02] 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·
# 적응형 Crisis·섹터/상관 필터. 지정 수량은 상한으로 클램프. 판정은 try 밖이며
# 조회 실패는 매수 거부(fail-closed).
order_price = float(request.price) if request.price else current_price
if quantity is None:
try:
balance = await korea_investment_api.get_balance()
output2 = balance.get("output2") or [{}]
b = output2[0] if isinstance(output2, list) else output2
available_cash = float(b.get("dnca_tot_amt") or 0)
total_assets = float(b.get("tot_evlu_amt") or 0)
except Exception as e:
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
quantity = int(budget // order_price)
if quantity < 1:
raise HTTPException(
status_code=400,
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
)
current_holdings = (await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
)).scalars().all()
try:
guard = await evaluate_manual_buy_v2(
kis=korea_investment_api,
stock_code=stock_code,
stock_name=stock_name,
current_price=current_price,
order_price=order_price,
requested_quantity=request.quantity,
holding=holding,
current_holdings=list(current_holdings),
)
except BuyGuardRejected as rej:
logger.warning(f"[MANUAL_BUY] {stock_code} 거부({rej.reason}): {rej.detail}")
raise HTTPException(status_code=rej.status_code, detail=rej.detail)
quantity = guard.quantity
logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}")
@@ -186,4 +193,5 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession
"order_id": order_id,
"quantity": quantity,
"price": order_price,
**guard.to_response(),
}