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:
314
backend/tests/test_round3_fixes_20261002.py
Normal file
314
backend/tests/test_round3_fixes_20261002.py
Normal 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
|
||||
Reference in New Issue
Block a user