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:
@@ -39,6 +39,13 @@ from app.services.db_worker import (
|
||||
enqueue_portfolio_upsert,
|
||||
enqueue_trade_history,
|
||||
)
|
||||
from app.services.buy_guards import BuyGuardRejected, evaluate_manual_buy_v2
|
||||
from app.services.manual_sell_service import (
|
||||
allocate_sell_v2,
|
||||
dt_inmemory_qty_v2,
|
||||
reflect_dt_after_manual_sell_v2,
|
||||
sync_active_trade_after_manual_sell_v2,
|
||||
)
|
||||
from app.services.tenant.context import UserContext
|
||||
from app.services.tenant.registry import get_user_context, user_context_registry
|
||||
from app.services.trade_executor import safe_place_order_v2
|
||||
@@ -249,8 +256,8 @@ async def sync_portfolio_v2(
|
||||
if ctx.portfolio_cache is not None:
|
||||
try:
|
||||
ctx.portfolio_cache.mark_changed()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _swallowed:
|
||||
logger.warning("[portfolio_v2] portfolio_cache.mark_changed 실패: %s", _swallowed)
|
||||
return {"message": "Portfolio synchronized", "count": len(processed_codes), "user_id": ctx.user_id}
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -336,24 +343,32 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
|
||||
if cnt >= settings.MAX_HOLDINGS_COUNT:
|
||||
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
|
||||
|
||||
quantity = request.quantity
|
||||
# ── 사이징·자본 가드 (CLAUDE.md §5.1, 공용 buy_guards — manual_buy 와 동일) ──
|
||||
# [H-4 2026-10-02] 종목당 4% 누적·현금 유보·레버리지 20%·최소주문 실효값·
|
||||
# 적응형 Crisis·섹터/상관 필터. 지정 수량은 상한으로 클램프. 판정은 try 밖.
|
||||
order_price = float(request.price) if request.price else current_price
|
||||
if quantity is None:
|
||||
try:
|
||||
balance = await ctx.kis.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, _user_scope_filter(Portfolio.user_id, ctx.user_id)
|
||||
)
|
||||
)).scalars().all()
|
||||
try:
|
||||
guard = await evaluate_manual_buy_v2(
|
||||
kis=ctx.kis,
|
||||
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),
|
||||
# DT 인메모리 상태는 레거시 계좌 소유 — 사용자 컨텍스트에는 섞지 않는다
|
||||
include_dt_inmemory=bool(ctx.is_legacy),
|
||||
)
|
||||
except BuyGuardRejected as rej:
|
||||
logger.warning(f"[MANUAL_BUY v2] user_id={ctx.user_id} {stock_code} 거부({rej.reason}): {rej.detail}")
|
||||
raise HTTPException(status_code=rej.status_code, detail=rej.detail)
|
||||
quantity = guard.quantity
|
||||
|
||||
logger.info(f"🟢 [v2] user_id={ctx.user_id} 수동 매수 요청: {stock_name}({stock_code}) {quantity}주")
|
||||
|
||||
@@ -419,6 +434,7 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess
|
||||
"order_id": order_id,
|
||||
"quantity": quantity,
|
||||
"price": order_price,
|
||||
**guard.to_response(),
|
||||
}
|
||||
|
||||
|
||||
@@ -456,12 +472,24 @@ async def sell_stock_v2(
|
||||
average_price = holding.average_price
|
||||
original_strategy_type = holding.strategy_type or "swing"
|
||||
|
||||
# [Low 3회차] DT 보유분은 주문 전에 확정(레거시 계좌만). 모르면 503 — /swing/sell·/sell 과 동일.
|
||||
_dt_qty = 0
|
||||
if ctx.is_legacy:
|
||||
_dt_qty = dt_inmemory_qty_v2(stock_code)
|
||||
if _dt_qty is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="데이트레이딩 보유분 확인 실패 — 매도 배분을 확정할 수 없습니다",
|
||||
)
|
||||
|
||||
order_result = await safe_place_order_v2(
|
||||
ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True,
|
||||
)
|
||||
if not order_result or order_result.get("rt_cd") != "0":
|
||||
error_msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
|
||||
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
|
||||
# order_lock_busy = 자동 청산 등 다른 경로가 같은 종목을 주문 중 → 재시도 가능(409)
|
||||
_status = 409 if error_msg == "order_lock_busy" else 500
|
||||
raise HTTPException(status_code=_status, detail=f"매도 주문 실패: {error_msg}")
|
||||
|
||||
executed_price = price if price else float(holding.current_price or 0)
|
||||
profit_loss = (executed_price - float(average_price or 0)) * quantity
|
||||
@@ -490,36 +518,29 @@ async def sell_stock_v2(
|
||||
sell_history_payload["user_id"] = ctx.user_id
|
||||
await enqueue_trade_history(sell_history_payload)
|
||||
|
||||
# Portfolio.strategy_type('daytrading'/'us_swing')과 ActiveTrade.strategy_name
|
||||
# ('day_trade'/'us_mean_reversion')은 값 체계가 달라 그대로 넘기면 매칭에 실패해
|
||||
# active 행이 영구 잔존한다(2026-09-23).
|
||||
_AT_STRATEGY = {"daytrading": "day_trade", "us_swing": "us_mean_reversion"}
|
||||
_at_name = _AT_STRATEGY.get(holding.strategy_type or "swing", holding.strategy_type or "swing")
|
||||
|
||||
if quantity >= original_holding_qty:
|
||||
await db.delete(holding)
|
||||
if ctx.is_legacy:
|
||||
# M3: legacy 스케줄러/기존 `/portfolio/sell`과 동일하게 user_id 없는 close 사용
|
||||
# (ActiveTrade.user_id == owner 필터로 NULL 행을 못 찾아 유령 포지션으로
|
||||
# 남는 결함 방지).
|
||||
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
|
||||
else:
|
||||
await enqueue_close_active_trade_v2(ctx.user_id, stock_code, strategy_name=_at_name)
|
||||
else:
|
||||
holding.quantity -= quantity
|
||||
holding.current_price = Decimal(str(executed_price))
|
||||
# 부분매도 시 ActiveTrade 잔량 갱신 — 누락 시 다음 청산이 매도 전 수량으로 판다
|
||||
from app.services.db_worker import enqueue_active_trade_upsert as _enq_at
|
||||
_at_payload = {
|
||||
"stock_code": stock_code,
|
||||
"strategy_name": _at_name,
|
||||
"quantity": original_holding_qty - quantity,
|
||||
"current_price": float(executed_price),
|
||||
}
|
||||
if not ctx.is_legacy:
|
||||
_at_payload["user_id"] = ctx.user_id
|
||||
await _enq_at(_at_payload)
|
||||
await db.commit()
|
||||
# [M-5 2026-10-02] ActiveTrade·Portfolio·DT 인메모리 반영.
|
||||
# 기존: (1) 보유 행의 strategy_type 한 가지로만 close/upsert → 합산 행(스윙+DT)에서
|
||||
# 다른 전략 행이 잔존/오폐기, (2) DT 인메모리 미차감 → DT 엔진이 판 수량을 재매도,
|
||||
# (3) `db.delete/commit` 직접 쓰기(§3.2 위반).
|
||||
# 수정: DT 우선 배분(레거시 /portfolio/sell·MDD 축소와 동일 규칙)으로 전략별 행을
|
||||
# 전략명 필터와 함께 갱신, DB 쓰기는 db_worker 큐 경유. DT 인메모리는 레거시 계좌 소유라
|
||||
# legacy 컨텍스트에서만 반영한다.
|
||||
alloc = allocate_sell_v2(quantity, original_holding_qty, _dt_qty, swing_only=False)
|
||||
await sync_active_trade_after_manual_sell_v2(
|
||||
stock_code, alloc, price=float(executed_price),
|
||||
user_id=None if ctx.is_legacy else ctx.user_id,
|
||||
)
|
||||
_pf_payload = {
|
||||
"stock_code": stock_code,
|
||||
# 0 → 목록에서 제외, 다음 sync 가 KIS 기준으로 정리(행 삭제는 sync 와 충돌)
|
||||
"quantity": max(0, original_holding_qty - quantity),
|
||||
"current_price": float(executed_price),
|
||||
}
|
||||
if not ctx.is_legacy:
|
||||
_pf_payload["user_id"] = ctx.user_id
|
||||
await enqueue_portfolio_upsert(_pf_payload)
|
||||
if ctx.is_legacy:
|
||||
await reflect_dt_after_manual_sell_v2(stock_code, alloc.dt_sold)
|
||||
|
||||
# LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지).
|
||||
# [2026-09-28] 수동 청산이 빠져 연패가 과소 집계되던 구멍을 막는다.
|
||||
|
||||
Reference in New Issue
Block a user