Files
auto-stock/backend/tests/test_trade_executor_risk_fixes_20261001.py
shuim 45677c8fbb fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor
- 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room)
- 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning
- 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed
- 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지)
- 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡

korea_investment_api
- get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld
  (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning

daytrading
- 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제)
- 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler)
- legacy _record_trade strategy_type=daytrading 명시

db_worker
- update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지)

alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함)
CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014

998 passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:37:29 +09:00

648 lines
29 KiB
Python

"""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):
"""스윙(또는 legacy NULL) 행은 가드를 통과해 주문 관문까지 간다."""
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]