"""trade_executor 리스크 결함 3건 회귀 테스트 (2026-10-01). 1. 일일 매수 한도(MAX_DAILY_TRADES) 집계에서 데이트레이딩 매수 제외 2. 종목당 상한(MAX_POSITION_SIZE)에 기존 보유분 반영 (CLAUDE.md §5.1) 3. 추천 경로 매도(_execute_sell)에서 external/manual/DT 보유 행 제외 규약(CLAUDE.md §4.1): - 주문 경로 목은 **예외 발생형**이다(_Reached). 가드가 빗나가면 테스트가 터진다. - 목은 공통 종단(_acquire_order_lock — 매수·매도 주문 직전의 공통 관문)에 건다. - 기대값은 settings.* 참조. """ import ast import inspect import textwrap from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from sqlalchemy.dialects import postgresql from sqlalchemy import select from app.core.config import settings from app.models.models import TradeHistory from app.services import trade_executor from app.services.db_worker import DT_STRATEGY_NAMES from app.services.sizing_core import effective_min_buy_amount, remaining_position_room class _Reached(BaseException): """가드를 통과해 주문 관문에 도달했다는 신호. BaseException 파생 — 프로덕션의 `except Exception:` 폴백에 삼켜지지 않게(§4.1). """ class _Result: def __init__(self, rows=None): self._rows = list(rows or []) def scalars(self): return self def first(self): return self._rows[0] if self._rows else None def scalar_one_or_none(self): return self._rows[0] if self._rows else None def all(self): return list(self._rows) class _FakeDB: """execute 문장을 기록하고 빈 결과(또는 지정 결과)를 돌려준다. commit 은 호출되면 실패.""" def __init__(self, rows_for_first_call=None): self.statements = [] self._first_rows = rows_for_first_call async def execute(self, stmt): self.statements.append(stmt) if len(self.statements) == 1 and self._first_rows is not None: return _Result(self._first_rows) return _Result([]) async def commit(self): raise AssertionError("trade_executor 가 db.commit() 을 직접 호출했다 (§3.2 위반)") @pytest.fixture def _block_order_gate(monkeypatch): gate = AsyncMock(side_effect=_Reached("order gate reached")) monkeypatch.setattr(trade_executor, "_acquire_order_lock", gate) return gate @pytest.fixture def _empty_dt_state(monkeypatch): from app.services.daytrading.position_manager import day_trading_state monkeypatch.setattr(day_trading_state, "positions", {}) return day_trading_state # ═══════════════════════════════════════════════════════════════════════════ # 1. 일일 매수 한도에서 DT 제외 # ═══════════════════════════════════════════════════════════════════════════ def _compile(stmt) -> str: return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) @pytest.mark.asyncio async def test_daily_buy_limit_counts_only_swing_owned_buys(monkeypatch): """스윙 한도 → swing + NULL 레거시만 집계. DT·manual·external·us_swing 제외.""" monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False) # 오늘 스윙 매수 = 한도 → 조기 반환(추천 처리 없음) db = _FakeDB(rows_for_first_call=[f"{i:06d}" for i in range(settings.MAX_DAILY_TRADES)]) result = await trade_executor._execute_auto_trade_background_locked( {"recommendations_by_rule": []}, db ) assert result == [] assert db.statements, "당일 매수 집계 쿼리가 실행되지 않았다" sql = _compile(db.statements[0]) assert "trade_history.strategy_type IS NULL" in sql assert "trade_history.strategy_type IN ('swing')" in sql for excluded in (*DT_STRATEGY_NAMES, "manual", "external", "us_swing"): assert f"'{excluded}'" not in sql, f"{excluded} 가 집계 조건에 들어 있다" def test_daily_limit_and_sell_ownership_share_owner_set(): """일일 한도 집계와 매도 소유권이 같은 스윙 소유 기준을 쓴다.""" owners = trade_executor._SWING_OWNER_STRATEGY_TYPES f = trade_executor._sell_ownership_block_reason_v2 for st in owners: assert f(SimpleNamespace(strategy_type=st), {"strategy_type": "swing"}) is None sql = _compile(select(TradeHistory.id).where( trade_executor._swing_owned_clause_v2(TradeHistory.strategy_type))) for st in owners: assert f"'{st}'" in sql class _RaisingDB(_FakeDB): async def execute(self, stmt): self.statements.append(stmt) raise RuntimeError("db down") @pytest.mark.asyncio async def test_daily_buy_limit_fails_closed_on_query_error(monkeypatch, caplog): """집계 조회 실패 → 신규 진입 0건(fail-closed) + error 로그. execute_auto_trade 미호출.""" monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False) called = AsyncMock(side_effect=_Reached("execute_auto_trade reached")) monkeypatch.setattr(trade_executor, "execute_auto_trade", called) db = _RaisingDB() recs = {"recommendations_by_rule": [{"rule_name": "r", "stocks": [ {"stock_code": "005930", "stock_name": "t", "current_price": 70000, "score": 5}, ]}]} import logging with caplog.at_level(logging.ERROR, logger=trade_executor.logger.name): result = await trade_executor._execute_auto_trade_background_locked(recs, db) assert result == [] called.assert_not_called() assert any(r.levelno >= logging.ERROR and "fail-closed" in r.getMessage() for r in caplog.records) @pytest.mark.asyncio async def test_count_swing_buys_returns_none_on_error(): assert await trade_executor._count_swing_buys_today_v2(_RaisingDB()) is None def test_daily_limit_fail_closed_decision_outside_inner_try(): """fail-closed 판정(`_bought_today is None`)이 내부 try 안에 있지 않다(§4). 함수 전체를 감싼 최외곽 try 는 예외 시 빈/부분 결과를 반환하므로 fail-closed 방향이다. """ tree = ast.parse(textwrap.dedent(inspect.getsource( trade_executor._execute_auto_trade_background_locked))) fn = tree.body[0] outer = next(s for s in fn.body if isinstance(s, ast.Try)) def _is_none_check(n): return (isinstance(n, ast.Compare) and isinstance(n.left, ast.Name) and n.left.id == "_bought_today" and isinstance(n.ops[0], ast.Is)) assert any(_is_none_check(n) for n in ast.walk(outer)), "fail-closed 판정이 없다" for n in ast.walk(outer): if isinstance(n, ast.Try) and n is not outer: assert not any(_is_none_check(m) for m in ast.walk(n)), "판정이 내부 try 안에 있다" # ── 1-b. 부분매도 반복 차단(_sold_today)에서 DT 매도 제외 ───────────────── def test_non_dt_sold_today_query_uses_not_dt_clause(): import datetime as _dt captured = [] class _DB(_FakeDB): async def execute(self, stmt): captured.append(stmt) return _Result([]) import asyncio found = asyncio.run(trade_executor._non_dt_sold_today_v2( _DB(), "005930", _dt.datetime(2026, 10, 1))) assert found is False sql = _compile(captured[0]) assert "trade_history.strategy_type IS NULL" in sql assert "NOT IN" in sql for name in DT_STRATEGY_NAMES: assert f"'{name}'" in sql @pytest.mark.asyncio async def test_non_dt_sold_today_is_conservative_on_error(): import datetime as _dt assert await trade_executor._non_dt_sold_today_v2( _RaisingDB(), "005930", _dt.datetime(2026, 10, 1)) is True class _SellFlowDB: """execute_auto_trade 의 쿼리 순서를 흉내 낸다. 1) 보유 조회 → holding 2) 보유 수 → [holding] 3) 당일 SELL(전체) → dt_sell_row 4) 당일 비-DT SELL → non_dt_rows 그 외 → 빈 결과 """ def __init__(self, holding, any_sell_rows, non_dt_rows): self._seq = [[holding], [holding], any_sell_rows, non_dt_rows] self.n = 0 async def execute(self, stmt): rows = self._seq[self.n] if self.n < len(self._seq) else [] self.n += 1 return _Result(rows) async def commit(self): raise AssertionError("commit 금지") def _partial_exit_stock(): # check_exit_signal 이 부분매도를 내도록 모킹하고, 잔량 추적은 된 상태(remaining_pct=1.0) return { "stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0, "signals": [], "strategy_type": "swing", "remaining_pct": 1.0, "rsi": 56.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0, } def _patch_exit_flow(monkeypatch, sell_pct): monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) monkeypatch.setattr(settings, "NEWS_ENABLED", False) monkeypatch.setattr( trade_executor, "check_exit_signal", lambda **kw: SimpleNamespace(action="SELL_PARTIAL", sell_pct=sell_pct, reason="RSI test"), ) sell = AsyncMock(side_effect=_Reached("sell reached")) monkeypatch.setattr(trade_executor, "_execute_sell", sell) return sell @pytest.mark.asyncio async def test_dt_only_sell_today_does_not_block_swing_partial_exit(monkeypatch): """오늘 매도가 DT 것뿐이면 스윙 부분매도는 진행된다.""" sell = _patch_exit_flow(monkeypatch, 0.5) holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0, profit_loss_rate=10.0, stock_name="테스트") db = _SellFlowDB(holding, any_sell_rows=["dt-sell"], non_dt_rows=[]) with pytest.raises(_Reached): await trade_executor.execute_auto_trade(_partial_exit_stock(), db) assert sell.await_args.kwargs.get("sell_pct") == 0.5 @pytest.mark.asyncio async def test_swing_sell_today_still_blocks_partial_exit(monkeypatch): """오늘 스윙 매도가 있으면 부분매도는 기존대로 차단된다.""" sell = _patch_exit_flow(monkeypatch, 0.5) holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0, profit_loss_rate=10.0, stock_name="테스트") db = _SellFlowDB(holding, any_sell_rows=["swing-sell"], non_dt_rows=["swing-sell"]) res = await trade_executor.execute_auto_trade(_partial_exit_stock(), db) assert res == {"action": "SKIP", "reason": "cooldown_sell_today", "stock": "테스트"} sell.assert_not_called() @pytest.mark.asyncio async def test_dt_sell_today_still_blocks_rebuy_without_holding(monkeypatch): """당일 재매수 차단은 DT 매도도 포함(무보유 종목 → 즉시 SKIP).""" monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) buy = AsyncMock(side_effect=_Reached("buy reached")) monkeypatch.setattr(trade_executor, "_execute_buy", buy) db = _SellFlowDB(None, any_sell_rows=["dt-sell"], non_dt_rows=[]) db._seq = [[], [], ["dt-sell"], []] res = await trade_executor.execute_auto_trade( {"stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0, "signals": ["x"], "rule_id": 1}, db) assert res["reason"] == "cooldown_sell_today" buy.assert_not_called() # ═══════════════════════════════════════════════════════════════════════════ # 2. 종목당 누적 상한 # ═══════════════════════════════════════════════════════════════════════════ TOTAL = 100_000_000.0 def test_remaining_position_room_pure(): cap = settings.MAX_POSITION_SIZE assert remaining_position_room(TOTAL, cap, 0) == pytest.approx(TOTAL * cap) assert remaining_position_room(TOTAL, cap, TOTAL * cap * 0.5) == pytest.approx(TOTAL * cap * 0.5) assert remaining_position_room(TOTAL, cap, TOTAL * cap * 2) == 0.0 # 초과 → floor 0 assert remaining_position_room(0, cap, 1) == 0.0 assert remaining_position_room(TOTAL, 0, 0) == 0.0 assert remaining_position_room(TOTAL, cap, -5) == pytest.approx(TOTAL * cap) def test_position_cap_no_holding_is_unchanged(_empty_dt_state): amt, reason = trade_executor._apply_position_cap_room_v2( invest_amount=1_234_567.0, holding=None, stock_code="005930", stock_name="t", current_price=10_000.0, total_assets=TOTAL, position_cap_ratio=settings.MAX_POSITION_SIZE, ) assert reason is None and amt == 1_234_567.0 def test_position_cap_clamps_to_remaining_room(_empty_dt_state): cap = settings.MAX_POSITION_SIZE price = 10_000.0 held_qty = int(TOTAL * cap * 0.5 / price) # 상한의 절반 보유 holding = SimpleNamespace(quantity=held_qty, strategy_type="swing") amt, reason = trade_executor._apply_position_cap_room_v2( invest_amount=TOTAL * cap, holding=holding, stock_code="005930", stock_name="t", current_price=price, total_assets=TOTAL, position_cap_ratio=cap, ) assert reason is None assert amt == pytest.approx(TOTAL * cap - held_qty * price) assert (held_qty * price + amt) / TOTAL <= cap + 1e-9 def test_position_cap_reached_skips(_empty_dt_state): cap = settings.MAX_POSITION_SIZE price = 10_000.0 min_buy = effective_min_buy_amount(settings.MIN_BUY_AMOUNT_KRW, TOTAL, cap, 0.9) # 잔여 한도가 최소주문금액보다 1주만큼 작게 남도록 보유 held_value = TOTAL * cap - (min_buy - price) holding = SimpleNamespace(quantity=int(held_value // price) + 1, strategy_type="swing") amt, reason = trade_executor._apply_position_cap_room_v2( invest_amount=TOTAL * cap, holding=holding, stock_code="005930", stock_name="t", current_price=price, total_assets=TOTAL, position_cap_ratio=cap, ) assert reason == "position_cap_reached" and amt == 0.0 def test_position_cap_counts_dt_inmemory_qty(_empty_dt_state): """sync 전 DT 매수분(행에 미반영)도 센다 — max(행, DT).""" cap = settings.MAX_POSITION_SIZE price = 10_000.0 dt_qty = int(TOTAL * cap / price) # DT 만으로 상한 도달 _empty_dt_state.positions["005930"] = {"quantity": dt_qty} amt, reason = trade_executor._apply_position_cap_room_v2( invest_amount=TOTAL * cap, holding=None, stock_code="005930", stock_name="t", current_price=price, total_assets=TOTAL, position_cap_ratio=cap, ) assert reason == "position_cap_reached" def test_position_cap_unverifiable_without_total_assets(_empty_dt_state): holding = SimpleNamespace(quantity=10, strategy_type="swing") amt, reason = trade_executor._apply_position_cap_room_v2( invest_amount=1_000_000.0, holding=holding, stock_code="005930", stock_name="t", current_price=10_000.0, total_assets=0.0, position_cap_ratio=settings.MAX_POSITION_SIZE, ) assert reason == "position_cap_unverifiable" and amt == 0.0 def test_position_cap_guard_is_outside_try(): """_execute_buy 에서 누적 상한 가드 호출이 try 블록 밖(함수 최상위 문장)에 있다.""" tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_buy))) fn = tree.body[0] def _calls_guard(node): return any( isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "_apply_position_cap_room_v2" for n in ast.walk(node) ) for n in ast.walk(fn): if isinstance(n, ast.Try): assert not _calls_guard(n), "누적 상한 가드가 try 안에 있다 (§4 위반)" assert any(_calls_guard(s) for s in fn.body), "누적 상한 가드 호출이 최상위에 없다" # 1건 절대 상한 클램프보다 뒤에 있어야 한다(패턴메모리 상향분까지 묶음) idx_guard = next(i for i, s in enumerate(fn.body) if _calls_guard(s)) idx_clamp = next( i for i, s in enumerate(fn.body) if any(isinstance(n, ast.Name) and n.id == "clamp_by_ratios" for n in ast.walk(s)) and any(isinstance(n, ast.Attribute) and n.attr == "MAX_POSITION_SIZE" for n in ast.walk(s)) and not isinstance(s, ast.Try) ) assert idx_clamp < idx_guard def _patch_buy_pipeline(monkeypatch, total, cash, recorded): monkeypatch.setattr(trade_executor, "check_opening_range_volatility", AsyncMock(return_value=False)) monkeypatch.setattr(trade_executor, "check_surge_prevention", AsyncMock(return_value={})) monkeypatch.setattr(trade_executor, "check_trend_filter", AsyncMock(return_value={})) monkeypatch.setattr(trade_executor, "_get_cached_account", lambda: (cash, total)) monkeypatch.setattr(trade_executor, "validate_correlation_filter", AsyncMock(return_value=(True, "ok"))) monkeypatch.setattr(trade_executor, "calculate_cooldown_minutes", lambda *a, **k: 0) # Kelly 가 상한까지 꽉 채운 예산을 낸다고 가정 monkeypatch.setattr( trade_executor, "_calculate_kelly_position", AsyncMock(return_value=total * settings.MAX_POSITION_SIZE), ) import app.services.adaptive_strategy as _ad monkeypatch.setattr(_ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) import app.services.stock_pattern_memory as _pm async def _identity(stock_code, base_invest_amount, **kw): return base_invest_amount monkeypatch.setattr(_pm, "get_pattern_adjusted_invest_amount", _identity) _orig_a2q = trade_executor.amount_to_quantity def _rec(amount, price): recorded.append(amount) return _orig_a2q(amount, price) monkeypatch.setattr(trade_executor, "amount_to_quantity", _rec) async def _call_buy(holding, price): return await trade_executor._execute_buy( stock={"_regime_str": "bull"}, db=_FakeDB(), holding=holding, current_price=price, signals=["x"], score_val=5.0, is_domestic=True, exchange_code="KRX", stock_code="005930", stock_name="테스트", ) @pytest.mark.asyncio async def test_execute_buy_add_on_is_capped_by_existing_holding( monkeypatch, _block_order_gate, _empty_dt_state ): recorded = [] _patch_buy_pipeline(monkeypatch, TOTAL, TOTAL, recorded) cap = settings.MAX_POSITION_SIZE price = 10_000.0 held_qty = int(TOTAL * cap * 0.5 / price) holding = SimpleNamespace(quantity=held_qty, strategy_type="swing", stock_name="테스트", current_price=price, average_price=price) with pytest.raises(_Reached): await _call_buy(holding, price) assert recorded, "주수 환산까지 도달하지 않았다" assert recorded[-1] <= TOTAL * cap - held_qty * price + 1e-6 assert (held_qty * price + recorded[-1]) / TOTAL <= cap + 1e-9 @pytest.mark.asyncio async def test_execute_buy_skips_when_holding_at_cap( monkeypatch, _block_order_gate, _empty_dt_state ): recorded = [] _patch_buy_pipeline(monkeypatch, TOTAL, TOTAL, recorded) price = 10_000.0 held_qty = int(TOTAL * settings.MAX_POSITION_SIZE / price) holding = SimpleNamespace(quantity=held_qty, strategy_type="swing", stock_name="테스트", current_price=price, average_price=price) res = await _call_buy(holding, price) assert res["action"] == "SKIP" and res["reason"] == "position_cap_reached" _block_order_gate.assert_not_called() # ═══════════════════════════════════════════════════════════════════════════ # 3. 추천 경로 매도에서 external / manual / DT 보유 행 제외 # ═══════════════════════════════════════════════════════════════════════════ def _holding(st): return SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0, profit_loss_rate=0.0, stock_name="테스트") async def _call_sell(holding, stock=None, sell_pct=1.0): return await trade_executor._execute_sell( stock=stock if stock is not None else {"strategy_type": "swing"}, db=_FakeDB(), holding=holding, current_price=9_000.0, signals=["ATR 손절"], is_domestic=True, exchange_code="KRX", stock_code="005930", stock_name="테스트", sell_pct=sell_pct, ) @pytest.mark.asyncio @pytest.mark.parametrize("st,reason", [ ("external", "manual_judgment_holding"), ("manual", "manual_judgment_holding"), ("daytrading", "owned_by_daytrading"), ("day_trade", "owned_by_daytrading"), ("us_swing", "owned_by_other_strategy"), ]) async def test_sell_skips_non_swing_holdings(st, reason, _block_order_gate, _empty_dt_state): res = await _call_sell(_holding(st)) assert res == {"action": "SKIP", "reason": reason, "stock": "테스트"} _block_order_gate.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize("st", ["swing", None]) async def test_sell_allows_swing_holdings(st, _block_order_gate, _empty_dt_state, monkeypatch): """스윙(또는 legacy NULL) 행은 가드를 통과해 주문 관문까지 간다. [2026-10-09] 매도 직전 KIS 실보유 상한 조회(_recommendation_sell_kis_cap_v2)가 주문 락보다 앞에 생겨 그 지점도 '소유권 가드 통과 후 주문 직전 관문'으로 본다(예외 발생형). """ monkeypatch.setattr( trade_executor, "_recommendation_sell_kis_cap_v2", AsyncMock(side_effect=_Reached("kis cap gate reached")), ) with pytest.raises(_Reached): await _call_sell(_holding(st)) def test_ownership_rule_owner_caller_allowed(): """호출자 태그가 소유 엔진과 같으면 허용(현재 그런 호출자는 없지만 계약 고정).""" f = trade_executor._sell_ownership_block_reason_v2 assert f(_holding("daytrading"), {"strategy_type": "daytrading"}) is None assert f(_holding("us_swing"), {"strategy_type": "us_swing"}) is None # external/manual 은 호출자 태그와 무관하게 항상 차단 assert f(_holding("manual"), {"strategy_type": "manual"}) == "manual_judgment_holding" assert f(_holding("external"), {"strategy_type": "external"}) == "manual_judgment_holding" def test_ownership_guard_is_outside_try_and_first(): """_execute_sell 의 소유권 가드는 try 밖, 주문 관문보다 앞에 있다.""" tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_sell))) fn = tree.body[0] def _calls(node, name): return any( isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == name for n in ast.walk(node) ) for n in ast.walk(fn): if isinstance(n, ast.Try): assert not _calls(n, "_sell_ownership_block_reason_v2") idx_guard = next(i for i, s in enumerate(fn.body) if _calls(s, "_sell_ownership_block_reason_v2")) idx_lock = next(i for i, s in enumerate(fn.body) if _calls(s, "_acquire_order_lock")) assert idx_guard < idx_lock def test_mdd_reduction_does_not_route_through_execute_sell(): """MDD 강제축소는 _execute_sell / execute_auto_trade 를 쓰지 않는다 (§8.1-11).""" from app.services import mdd_manager as _mdd src = inspect.getsource(_mdd) assert "_execute_sell" not in src assert "execute_auto_trade" not in src # ═══════════════════════════════════════════════════════════════════════════ # H-1. 팔 수 없는 행에는 사지 않는다 (매수·매도 소유권 대칭) # M-1. 고아 daytrading 행 skip 은 warning # ═══════════════════════════════════════════════════════════════════════════ def _patch_buy_side_flow(monkeypatch, exit_signal=None): monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) monkeypatch.setattr(settings, "NEWS_ENABLED", False) monkeypatch.setattr(trade_executor, "check_exit_signal", lambda **kw: exit_signal) buy = AsyncMock(side_effect=_Reached("buy reached")) sell = AsyncMock(side_effect=_Reached("sell reached")) monkeypatch.setattr(trade_executor, "_execute_buy", buy) monkeypatch.setattr(trade_executor, "_execute_sell", sell) return buy, sell def _rule_buy_stock(): return { "stock_code": "005930", "stock_name": "테스트", "current_price": 10_000.0, "signals": ["x"], "rule_id": 1, "strategy_type": "swing", "remaining_pct": 1.0, "rsi": 40.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0, } def _flow_db(holding): db = _SellFlowDB(holding, any_sell_rows=[], non_dt_rows=[]) db._seq = [[holding], [holding], []] return db @pytest.mark.asyncio @pytest.mark.parametrize("st,reason", [ ("manual", "manual_judgment_holding"), ("daytrading", "owned_by_daytrading"), ("day_trade", "owned_by_daytrading"), ("us_swing", "owned_by_other_strategy"), ]) async def test_buy_blocked_on_unsellable_holding(monkeypatch, st, reason): buy, _ = _patch_buy_side_flow(monkeypatch) holding = SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0, profit_loss_rate=0.0, stock_name="테스트") res = await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding)) assert res == {"action": "SKIP", "reason": f"non_swing_holding:{reason}", "stock": "테스트"} buy.assert_not_called() @pytest.mark.asyncio @pytest.mark.parametrize("st", ["swing", None]) async def test_buy_allowed_on_swing_holding(monkeypatch, st): buy, _ = _patch_buy_side_flow(monkeypatch) holding = SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0, profit_loss_rate=0.0, stock_name="테스트") with pytest.raises(_Reached): await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding)) buy.assert_awaited() @pytest.mark.asyncio async def test_buy_block_does_not_stop_exit_judgment(monkeypatch): """_guard 경유 — 매수만 막고 청산 판정은 _execute_sell 까지 도달한다(§8.1-11).""" _, sell = _patch_buy_side_flow( monkeypatch, exit_signal=SimpleNamespace(action="SELL_ALL", sell_pct=1.0, reason="test full exit"), ) holding = SimpleNamespace(quantity=10, strategy_type="manual", average_price=10_000.0, profit_loss_rate=0.0, stock_name="테스트") with pytest.raises(_Reached): await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding)) sell.assert_awaited() def test_buy_side_block_mirrors_sell_ownership_and_is_not_in_inner_try(): tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor.execute_auto_trade))) fn = tree.body[0] outer = next(s for s in fn.body if isinstance(s, ast.Try)) def _calls(node): return any( isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "_sell_ownership_block_reason_v2" for n in ast.walk(node) ) assert _calls(outer), "매수 측이 매도 소유권 판정을 쓰지 않는다" for n in ast.walk(outer): if isinstance(n, ast.Try) and n is not outer: assert not _calls(n), "소유권 매수 차단이 내부 try 안에 있다" @pytest.mark.asyncio async def test_orphan_daytrading_row_sell_skip_logs_warning( _block_order_gate, _empty_dt_state, caplog ): import logging with caplog.at_level(logging.INFO, logger=trade_executor.logger.name): res = await _call_sell(_holding("daytrading")) assert res["reason"] == "owned_by_daytrading" warns = [r for r in caplog.records if r.levelno == logging.WARNING and "수동 확인 필요" in r.getMessage()] assert warns, "고아 daytrading 행이 warning 으로 남지 않았다" assert "005930" in warns[0].getMessage() and "10주" in warns[0].getMessage() @pytest.mark.asyncio async def test_live_daytrading_row_sell_skip_stays_info( _block_order_gate, _empty_dt_state, caplog ): import logging _empty_dt_state.positions["005930"] = {"quantity": 10} with caplog.at_level(logging.INFO, logger=trade_executor.logger.name): res = await _call_sell(_holding("daytrading")) assert res["reason"] == "owned_by_daytrading" assert not [r for r in caplog.records if r.levelno >= logging.WARNING]