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>
648 lines
29 KiB
Python
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]
|