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,314 @@
"""[3회차 2026-10-02] 검증 결함 회귀 — H-1 / M-1 / M-2 / M-3 / M-4 / Low.
(C-1·C-2·mid-loop 영속은 tests/test_mdd_reduction_retry_20261002.py)
주문 종단은 캡처형, 호출되면 안 되는 경로는 예외 발생형. 기대값은 settings.* 기준.
"""
import inspect
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app.core.config import settings
from app.services import trade_executor as te
class _MustNotCall(BaseException):
pass
def _boom(name):
async def _f(*a, **k):
raise _MustNotCall(f"{name} 가 호출되면 안 된다")
return _f
async def _nosleep(*a, **k):
return None
# ═════════════════════════════════════════════════════════════════════════════
# H-1: /us-trading/execute — SELL 허용(가드·부기), BUY 410
# ═════════════════════════════════════════════════════════════════════════════
@pytest.fixture
def us_env(monkeypatch):
from app.api.endpoints import us_trading as us
from app.services.korea_investment_api import korea_investment_api as k
import app.services.db_worker as dbw
import app.services.portfolio_cache_bridge as pcb
from app.services.loss_tracker import loss_tracker
state = {"balance": {"rt_cd": "0", "output1": [{
"ovrs_pdno": "AAPL", "ovrs_item_name": "Apple", "ovrs_excg_cd": "NASD",
"ord_psbl_qty": "5", "ovrs_cblc_qty": "5", "pchs_avg_pric": "200", "now_pric2": "180",
}]}}
monkeypatch.setattr(k, "get_overseas_balance", AsyncMock(side_effect=lambda: state["balance"]))
orders, writes, reflected = [], [], []
async def _order(**kw):
orders.append(kw)
return {"rt_cd": "0", "output": {"ODNO": "7"}}
monkeypatch.setattr(te, "safe_place_order", _order)
async def _noop(*a, **k):
return None
async def _w(task):
writes.append(task)
async def _reflect(code, qty, price, remaining_qty=None):
reflected.append((code, qty, remaining_qty))
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(dbw, "enqueue_db_write", _w)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop)
monkeypatch.setattr(pcb, "reflect_sell", _reflect)
rec, save = AsyncMock(), AsyncMock()
monkeypatch.setattr(loss_tracker, "record_trade_result_safe", rec)
monkeypatch.setattr(loss_tracker, "save_to_db", save)
return SimpleNamespace(us=us, state=state, orders=orders, writes=writes,
reflected=reflected, rec=rec, save=save)
@pytest.mark.asyncio
async def test_h1_us_buy_still_gone(us_env, monkeypatch):
monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order"))
with pytest.raises(HTTPException) as ei:
await us_env.us.execute_us_trade(us_env.us.UsOrderRequest(ticker="AAPL", order_type="buy", quantity=1))
assert ei.value.status_code == 410
@pytest.mark.asyncio
async def test_h1_us_sell_allowed_even_when_flag_off(us_env, monkeypatch):
e = us_env
monkeypatch.setattr(settings, "US_TRADING_ENABLED", False)
with pytest.raises(HTTPException) as ei:
await e.us.execute_us_trade(e.us.UsOrderRequest(ticker="AAPL", order_type="sell", quantity=6))
assert ei.value.status_code == 400 and e.orders == []
out = await e.us.execute_us_trade(e.us.UsOrderRequest(ticker="aapl", order_type="sell", quantity=5))
assert out["success"] is True and out["remaining"] == 0
o = e.orders[0]
assert o["is_domestic"] is False and o["exchange_code"] == "NASD" and o["quantity"] == 5
assert o["order_type"] == "SELL" and o["price"] == pytest.approx(180.0)
# 전량 → 비-DT ActiveTrade close + loss_tracker record/save
assert e.writes and "strategy_name_not" in e.writes[0]["data"]
e.rec.assert_awaited_once()
e.save.assert_awaited_once()
@pytest.mark.asyncio
async def test_h1_us_sell_rejects_on_balance_error(us_env, monkeypatch):
e = us_env
monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order"))
e.state["balance"] = {"rt_cd": "1", "msg_cd": "EGW00201"}
with pytest.raises(HTTPException) as ei:
await e.us.execute_us_trade(e.us.UsOrderRequest(ticker="AAPL", order_type="sell", quantity=1))
assert ei.value.status_code == 502
# ═════════════════════════════════════════════════════════════════════════════
# M-1: 매수 슬롯 = BUY&success, 전체 후보 순회, 보유 종목 청산 판정 계속, 거부 쿨다운
# ═════════════════════════════════════════════════════════════════════════════
class _Res:
def __init__(self, v):
self.v = v
def scalars(self):
return SimpleNamespace(all=lambda: list(self.v))
class _DB:
def __init__(self, held):
self.held = held
async def execute(self, stmt):
return _Res(self.held)
@pytest.fixture
def loop_env(monkeypatch):
monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False)
monkeypatch.setattr(settings, "MAX_DAILY_TRADES", 2)
monkeypatch.setattr(te, "_regime_limits_v2", lambda _r: (20, 1.0))
monkeypatch.setattr(te, "_count_swing_buys_today_v2", AsyncMock(return_value=0))
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
te._order_reject_cooldown.clear()
te._account_reject_at = None
te._eval_rotation_offset = 0
seen = []
script = {}
async def _eat(stock, db):
seen.append((stock["stock_code"], bool(stock.get("_exit_only"))))
return script.get(stock["stock_code"], {"action": "HOLD", "reason": "x"})
monkeypatch.setattr(te, "execute_auto_trade", _eat)
yield SimpleNamespace(seen=seen, script=script)
te._order_reject_cooldown.clear()
te._account_reject_at = None
te._eval_rotation_offset = 0
def _recs(codes):
return {"recommendations_by_rule": [{"rule_name": "r", "stocks": [
{"stock_code": c, "rule_priority": 1, "score": 100 - i} for i, c in enumerate(codes)
]}]}
@pytest.mark.asyncio
async def test_m1_skip_hold_do_not_consume_slots(loop_env):
e = loop_env
e.script.update({
"A": {"action": "HOLD"}, # 보유 — 청산 판정
"B": {"action": "SKIP", "reason": "already_ordered_today"},
"C": {"action": "BUY", "success": True},
"D": {"action": "BUY", "success": False},
"E": {"action": "BUY", "success": True},
"F": {"action": "BUY", "success": True}, # 슬롯 소진 → 평가 안 됨
"G": {"action": "SELL", "success": True}, # 보유 — 슬롯 무관 청산 판정
})
out = await te._execute_auto_trade_background_locked(_recs(list("ABCDEFG")), _DB(["A", "G"]))
codes = [c for c, _ in e.seen]
# [4회차 L-3] 순회 순서 = 보유 종목(청산 판정 우선) → 신규 후보
assert codes == ["A", "G", "B", "C", "D", "E"], codes
assert sum(1 for r in out if r["action"] == "BUY" and r.get("success")) == settings.MAX_DAILY_TRADES
@pytest.mark.asyncio
async def test_m1_evaluation_cap_and_reject_cooldown(loop_env, monkeypatch):
e = loop_env
monkeypatch.setattr(settings, "AUTO_TRADE_MAX_EVALUATIONS_PER_CYCLE", 2)
te._note_order_reject_v2("B", {"rt_cd": "1", "msg1": "주문가능수량 부족"})
await te._execute_auto_trade_background_locked(_recs(list("ABCD")), _DB(["D"]))
codes = [c for c, _ in e.seen]
# 보유 D 먼저(상한 무관), B 쿨다운 skip, 신규 평가 상한 2(A,C)
assert codes == ["D", "A", "C"], codes
def test_m1_reject_cooldown_expires(monkeypatch):
te._order_reject_cooldown.clear()
te._note_order_reject_v2("X", {"rt_cd": "1"})
assert te._order_reject_cooldown_remaining_v2("X") > 0
monkeypatch.setattr(settings, "AUTO_TRADE_ORDER_REJECT_COOLDOWN_MIN", 0.0)
assert te._order_reject_cooldown_remaining_v2("X") == 0.0
assert "X" not in te._order_reject_cooldown
def test_m1_execute_buy_records_business_rejection_only():
src = inspect.getsource(te._execute_buy)
i = src.index("_note_order_reject_v2(stock_code, order_result)")
assert "not _is_retryable_order_failure(order_result)" in src[max(0, i - 200):i]
assert te._is_retryable_order_failure({"rt_cd": "1", "msg_cd": "APBK0013"}) is False
# ═════════════════════════════════════════════════════════════════════════════
# M-2: 토큰 무효 코드는 재시도
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
@pytest.mark.parametrize("code", ["EGW00121", "EGW00123"])
async def test_m2_token_invalid_is_retried(monkeypatch, code):
k = te.korea_investment_api # trade_executor 가 실제로 참조하는 객체(모듈 재적재 대비)
assert te._is_retryable_order_failure({"rt_cd": "1", "msg_cd": code}) is True
calls = []
async def _po(*a, **kw):
calls.append(a)
return {"rt_cd": "1", "msg_cd": code} if len(calls) == 1 else {"rt_cd": "0", "output": {"ODNO": "1"}}
monkeypatch.setattr(k, "place_order", _po)
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
r = await te._place_order_with_retry(True, "005930", "SELL", 1, 70000)
assert r["rt_cd"] == "0" and len(calls) == 2
# ═════════════════════════════════════════════════════════════════════════════
# M-3: 현금 정의 단일화
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_m3_resolve_available_cash_priority():
from app.services.buy_guards import resolve_available_cash_v2
out2 = {"dnca_tot_amt": "-913965", "prvs_rcdl_excc_amt": "5224855"}
k = SimpleNamespace(get_buyable_cash=AsyncMock(return_value=3_000_000))
assert await resolve_available_cash_v2(k, out2, "005930") == 3_000_000
k = SimpleNamespace(get_buyable_cash=AsyncMock(return_value=0))
assert await resolve_available_cash_v2(k, out2, "005930") == 5_224_855.0
async def _fail(*a, **kw):
raise RuntimeError("x")
k = SimpleNamespace(get_buyable_cash=_fail)
assert await resolve_available_cash_v2(k, out2, "005930") == 5_224_855.0
def test_m3_trade_executor_uses_shared_cash_definition():
src = inspect.getsource(te._execute_buy)
assert "resolve_available_cash_v2(" in src
assert 'b.get("dnca_tot_amt"' not in src
# ═════════════════════════════════════════════════════════════════════════════
# M-4: 사이클 매수 누적 — 취소 확정 시 제외, 해외 매수 국내 보유수 제외
# ═════════════════════════════════════════════════════════════════════════════
def test_m4_overseas_buys_excluded_from_domestic_count():
te._recent_cycle_buys.clear()
te._note_cycle_buy_v2("005930", "삼성", 1, 70000)
te._note_cycle_buy_v2("AAPL", "Apple", 1, 200)
assert te._domestic_recent_cycle_buy_codes_v2() == {"005930"}
te._recent_cycle_buys.clear()
@pytest.mark.asyncio
@pytest.mark.parametrize("remaining,expect_kept", [(0, False), (3, True)])
async def test_m4_dead_buy_removed_from_cycle_buys(monkeypatch, remaining, expect_kept):
k = te.korea_investment_api # trade_executor 가 실제로 참조하는 객체
te._recent_cycle_buys.clear()
te._note_cycle_buy_v2("005930", "삼성", 3, 70000)
monkeypatch.setattr(k, "get_order_execution", AsyncMock(return_value={
"ok": True, "found": True, "filled_qty": 0, "remaining_qty": remaining,
}))
monkeypatch.setattr(te.asyncio, "sleep", _nosleep)
async def _noop(*a, **kw):
return None
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
await te._verify_and_sync_execution("0001", "005930", "삼성", 3, 70000, "BUY", True, "KRX", 0, delay=0)
assert ("005930" in te._recent_cycle_buys) is expect_kept
te._recent_cycle_buys.clear()
# ═════════════════════════════════════════════════════════════════════════════
# Low
# ═════════════════════════════════════════════════════════════════════════════
def test_low_usd_buy_not_deducted_from_krw_cache():
src = inspect.getsource(te._execute_buy)
i = src.index("_deduct_account_cache_v2(")
assert "if is_domestic:" in src[max(0, i - 120):i]
def test_low_partial_sell_keeps_highest_since_entry():
src = inspect.getsource(te._execute_sell)
i = src.index('"highest_since_entry": max(')
assert 'stock.get("highest_since_entry")' in src[i:i + 200]
src2 = inspect.getsource(te.execute_auto_trade)
assert 'stock["highest_since_entry"] = float(_hse)' in src2
@pytest.mark.asyncio
async def test_low_legacy_sell_dt_unknown_is_503_before_order(monkeypatch):
from app.api.endpoints import portfolio as pf
import app.services.manual_sell_service as mss
monkeypatch.setattr(mss, "dt_inmemory_qty_v2", lambda code: None)
monkeypatch.setattr(te, "safe_place_order", _boom("safe_place_order"))
holding = SimpleNamespace(quantity=10, stock_name="삼성", average_price=1, strategy_type="swing",
current_price=1)
class _HDB:
async def execute(self, stmt):
return SimpleNamespace(scalar_one_or_none=lambda: holding)
with pytest.raises(HTTPException) as ei:
await pf.sell_stock(pf.SellRequest(stock_code="005930", quantity=3), db=_HDB())
assert ei.value.status_code == 503