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>
This commit is contained in:
2026-10-01 17:37:29 +09:00
parent 599de3b52e
commit 45677c8fbb
19 changed files with 2873 additions and 166 deletions

View File

@@ -108,6 +108,9 @@ _KIS_CLASS_METHODS = (
"get_balance",
"get_buyable_cash",
"get_overseas_balance",
# ── 체결 조회 계열 (2026-10-01: DT 체결가 확인이 백그라운드로 호출) ──
"get_todays_orders",
"get_order_execution",
)
_TRADE_EXECUTOR_FUNCS = (
@@ -202,3 +205,9 @@ def _block_live_kis(request, monkeypatch):
monkeypatch.setattr(
te_mod, name, _blocked(f"trade_executor.{name}"), raising=True
)
# [2026-10-01] DT 체결가 확인(daytrading/fill_reconciler)은 주문 직후 백그라운드 태스크를
# 띄운다. 유닛 테스트에서는 기본 OFF — 테스트 종료 뒤 남은 태스크가 차단된 KIS 를 치거나
# "Task was destroyed but it is pending" 노이즈를 내지 않게 한다. 검증하는 테스트만 켠다.
from app.core.config import settings as _settings
monkeypatch.setattr(_settings, "DAY_TRADE_FILL_CHECK_ENABLED", False, raising=False)

View File

@@ -0,0 +1,176 @@
"""update_trade_history_by_order_id 의 주문번호 중복 회귀 테스트 (2026-10-01).
KIS ODNO 는 일별 순번 → 과거일 행과 번호가 겹친다. 예전 구현(`order_id == X` +
scalar_one_or_none)은 MultipleResultsFound 로 단일 워커가 1+2+4초 재시도하며 큐를 막고
DLQ 로 보냈으며, 당일 행이 없으면 과거 행을 덮어썼다.
실제 워커 루프(db_writer_worker)로 큐를 처리하고 SQLite(in-memory)에 결과를 확인한다.
"""
import asyncio
from datetime import datetime, timedelta
from decimal import Decimal
from unittest.mock import patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.compiler import compiles
from app.core.timeutil import now_kst
from app.models.models import Base, TradeHistory
from app.services import db_worker
@compiles(JSONB, "sqlite")
def _jsonb_sqlite(element, compiler, **kw): # 테스트 전용 — SQLite 에 JSONB 타입이 없다
return "JSON"
@pytest_asyncio.fixture
async def session_factory():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[TradeHistory.__table__]))
yield async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
await engine.dispose()
async def _seed(factory, *rows):
async with factory() as s:
for r in rows:
s.add(TradeHistory(**r))
await s.commit()
def _row(order_id, created_at, order_type="SELL", strategy_type="daytrading", price=10_000):
return dict(stock_code="005930", order_type=order_type, order_quantity=1,
order_price=Decimal(price), executed_price=Decimal(price), status="COMPLETED",
order_id=order_id, created_at=created_at, strategy_type=strategy_type)
async def _drain(queue, factory):
"""실제 워커 경로로 큐 처리. 재시도(1+2+4초)가 일어나면 timeout 으로 실패한다."""
sleeps = []
real_sleep = asyncio.sleep
async def _spy_sleep(d, *a, **k):
sleeps.append(d)
return await real_sleep(0)
with patch.object(db_worker, "AsyncSessionLocal", factory), \
patch.object(db_worker, "get_db_task_queue", return_value=queue), \
patch.object(db_worker, "_save_to_dlq", side_effect=AssertionError("DLQ 적재 금지")), \
patch.object(db_worker.asyncio, "sleep", _spy_sleep):
worker = asyncio.create_task(db_worker.db_writer_worker())
await asyncio.wait_for(queue.join(), timeout=5)
worker.cancel()
try:
await worker
except (asyncio.CancelledError, Exception):
pass
return sleeps
async def _all(factory):
async with factory() as s:
return {r.id: r for r in (await s.execute(select(TradeHistory))).scalars().all()}
@pytest.mark.asyncio
async def test_only_today_row_updated_when_past_day_shares_order_id(session_factory):
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
await _seed(session_factory,
_row("0000123", today - timedelta(days=3), price=5_000),
_row("0000123", today, price=10_000))
q = asyncio.Queue()
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "0000123", "executed_price": Decimal("10050")}})
sleeps = await _drain(q, session_factory)
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
assert float(rows[0].executed_price) == 5_000 # 과거 행 불변
assert float(rows[1].executed_price) == 10_050 # 당일 행 갱신
assert not [d for d in sleeps if d >= 1] # 재시도 백오프 없음
@pytest.mark.asyncio
async def test_payload_date_takes_priority_over_today(session_factory):
"""DLQ 재처리 등으로 다음 날 실행돼도 payload 기준일(order_date/executed_at)의 행을 고른다."""
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
yday = today - timedelta(days=1)
await _seed(session_factory, _row("777", yday, price=5_000), _row("777", today, price=6_000))
q = asyncio.Queue()
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "777", "order_date": yday.date().isoformat(),
"executed_price": Decimal("5100")}})
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "777", "executed_at": today, "status": "PARTIAL"}})
await _drain(q, session_factory)
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
assert float(rows[0].executed_price) == 5_100 and rows[0].status == "COMPLETED"
assert float(rows[1].executed_price) == 6_000 and rows[1].status == "PARTIAL"
@pytest.mark.asyncio
async def test_same_day_duplicates_update_exactly_one_row(session_factory, caplog):
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
await _seed(session_factory,
_row("0000555", today, order_type="BUY", strategy_type="daytrading"),
_row("0000555", today + timedelta(minutes=1), order_type="SELL", strategy_type="swing"),
_row("0000555", today + timedelta(minutes=2), order_type="SELL", strategy_type="daytrading"))
q = asyncio.Queue()
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "0000555", "order_type": "SELL", "strategy_type": "daytrading",
"executed_price": Decimal("12345")}})
await _drain(q, session_factory)
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
changed = [r for r in rows if float(r.executed_price) == 12_345]
assert len(changed) == 1
assert changed[0].order_type == "SELL" and changed[0].strategy_type == "daytrading"
assert any("중복" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_duplicates_without_filters_pick_latest_and_keep_strategy(session_factory):
"""필터 키가 없으면 최신 1행. 필터 키(strategy_type/order_type)는 setattr 되지 않는다."""
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
await _seed(session_factory,
_row("42", today, strategy_type="swing"),
_row("42", today + timedelta(minutes=5), strategy_type="swing"))
q = asyncio.Queue()
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "42", "strategy_type": "daytrading", "executed_price": Decimal("1")}})
await _drain(q, session_factory)
rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at)
assert float(rows[1].executed_price) == 1 and float(rows[0].executed_price) == 10_000
assert all(r.strategy_type == "swing" for r in rows)
@pytest.mark.asyncio
async def test_zero_padding_normalized(session_factory):
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
await _seed(session_factory, _row("0000000088", today))
q = asyncio.Queue()
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "88", "executed_price": Decimal("9999")}})
await _drain(q, session_factory)
(row,) = (await _all(session_factory)).values()
assert float(row.executed_price) == 9_999 and row.order_id == "0000000088"
@pytest.mark.asyncio
async def test_no_match_does_not_stall_queue(session_factory, caplog):
"""0건(당일 행 없음, 과거 행만 존재) → warning 후 종료. 과거 행 불변, 재시도·DLQ 없음, 다음 태스크 진행."""
today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0)
await _seed(session_factory, _row("999", today - timedelta(days=2), price=5_000))
q = asyncio.Queue()
await q.put({"type": "update_trade_history_by_order_id",
"data": {"order_id": "999", "executed_price": Decimal("1")}})
await q.put({"type": "insert_trade_history", "data": _row("1000", today)})
sleeps = await _drain(q, session_factory)
rows = await _all(session_factory)
assert len(rows) == 2
assert all(float(r.executed_price) != 1 for r in rows.values())
assert not [d for d in sleeps if d >= 1]
assert any("not found" in r.message for r in caplog.records)

View File

@@ -0,0 +1,338 @@
"""DT 결함 3건 회귀 테스트 (2026-10-01).
1. 시간손절은 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지) 집계에서 제외(중립).
하드 손절·폴백 손절·일일한도 청산 등은 기존대로 기록 + save_to_db.
2. 실현손익은 수수료·거래세 차감 순손익. 실제 체결가는 백그라운드로 확인해 원장·당일 손익 보정.
→ 일일 손실한도가 비용만큼 **더 일찍** 발동(보수적, 의도).
3. 레거시 `_record_trade` 가 strategy_type="daytrading" 을 명시.
목킹 원칙: 주문 경로는 예외 발생형(BaseException) — 목 누락이 거짓 통과가 되지 않게.
기대값은 settings.* 에서 계산(하드코딩 금지).
"""
import asyncio
from datetime import datetime, time, timedelta
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.services import day_trading_strategy as dts
from app.services.daytrading import exit_service as es
from app.services.daytrading import entry_service as ens
from app.services.daytrading import fill_reconciler as fr
from app.services.daytrading.position_manager import day_trading_state
from app.services.trade_cost_core import net_realized_pnl, round_trip_costs
class MustNotBeCalled(BaseException):
"""호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
def _boom(name):
async def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
def _expected_net(entry, exit_, qty, tax_rate=None):
"""settings 비용률로 독립 계산한 기대 순손익 (trade_helpers.calculate_trade_costs 와 같은 반올림)."""
tax = settings.KR_SELL_TAX_RATE_KOSPI if tax_rate is None else tax_rate
c = settings.KR_COMMISSION_RATE
cost = round(entry * qty * c + exit_ * qty * c + exit_ * qty * tax, 0)
return (exit_ - entry) * qty - cost, cost
@pytest.fixture
def dt_env():
"""_execute_exit → _execute_exit_impl → _finalize_exit_impl 을 실제로 타게 하되
외부 경계(주문·DB 큐·캐시·Redis)만 목으로 덮는다."""
import app.services.trade_executor as te
import app.services.portfolio_cache_bridge as pcb
captured = {"history": [], "updates": [], "upserts": []}
async def _hist(payload):
captured["history"].append(payload)
async def _upd(order_id, fields):
captured["updates"].append((order_id, fields))
async def _ups(payload):
captured["upserts"].append(payload)
saved_positions = dict(day_trading_state.positions)
saved_pnl = day_trading_state.daily_pnl
saved_streak = day_trading_state.loss_streak_today
saved_closed = set(day_trading_state.closed_today)
saved_stale = es._stale_realized_pnl
day_trading_state.positions.clear()
day_trading_state.daily_pnl = 0.0
day_trading_state.loss_streak_today = 0
with patch.object(te, "safe_place_order",
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \
patch.object(es, "enqueue_trade_history", _hist), \
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
patch("app.services.db_worker.enqueue_active_trade_upsert", _ups), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd), \
patch.object(ens, "enqueue_active_trade_upsert", _ups), \
patch.object(pcb, "reflect_sell", AsyncMock()), \
patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)):
yield captured
day_trading_state.positions.clear()
day_trading_state.positions.update(saved_positions)
day_trading_state.daily_pnl = saved_pnl
day_trading_state.loss_streak_today = saved_streak
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved_closed)
es._stale_realized_pnl = saved_stale
def _pos(entry=10_000.0, qty=10, order_id="0000999"):
return {"stock_name": "테스트", "entry_price": entry, "quantity": qty,
"highest_price": entry, "order_id": order_id}
# ── 1. 시간손절 중립 처리 ───────────────────────────────────────────────────
def test_time_stop_reason_from_strategy_is_classified():
"""check_day_trade_exit 가 실제로 만드는 시간손절 사유가 판별 함수와 일치해야 한다."""
from app.core.timeutil import now_kst
entry_time = now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1)
entry = 10_000.0
price = entry * (1 + settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 2) # 임계 미달
sig = dts.check_day_trade_exit(
entry_price=entry, current_price=price, stop_loss=entry * 0.5,
target_price=entry * 2, highest_since_entry=entry, candles_5min=[],
current_time=time(10, 0), entry_time=entry_time,
)
assert sig is not None and dts.is_time_stop_exit_reason(sig.reason), sig
# 손절·EOD·폴백·일일한도 사유는 시간손절이 아니다
for r in ("손절 -3.0% (현재=9700 ≤ 손절가=9700)", "EOD 강제 청산 (15:20 ≥ 15:20)",
"폴백 손절(분봉 미확보) -2.1%", "일일 손실 한도 초과", "", None):
assert not dts.is_time_stop_exit_reason(r)
@pytest.mark.asyncio
async def test_time_stop_exit_not_recorded_in_loss_tracker(dt_env):
day_trading_state.positions["111111"] = _pos()
with patch.object(es.loss_tracker, "record_trade_result_safe", _boom("record_trade_result_safe")), \
patch.object(es.loss_tracker, "save_to_db", _boom("save_to_db")):
res = await es._execute_exit(
"111111", day_trading_state.positions["111111"], 9_960.0,
f"{dts.TIME_STOP_REASON_PREFIX}: 31분 경과, 수익=-0.4% < -0.3%",
)
assert res is not None and res["profit_loss"] < 0
# 당일 연패 쿨다운(DT 전용·당일 한정)은 그대로 센다
assert day_trading_state.loss_streak_today == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("reason", [
"손절 -3.0% (현재=9700 ≤ 손절가=9700)",
"폴백 손절(분봉 미확보) -2.1%",
"일일 손실 한도 초과",
])
async def test_real_stop_exits_still_recorded_and_saved(dt_env, reason):
day_trading_state.positions["222222"] = _pos()
rec, save = AsyncMock(), AsyncMock()
with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
patch.object(es.loss_tracker, "save_to_db", save):
await es._execute_exit("222222", day_trading_state.positions["222222"], 9_700.0, reason)
rec.assert_awaited_once_with("222222", True)
save.assert_awaited_once_with(stock_code="222222")
# ── 2. 순손익 ──────────────────────────────────────────────────────────────
def test_cost_core_matches_swing_definition():
"""trade_cost_core 는 스윙 집행 경로(trade_helpers.calculate_trade_costs)와 같은 정의."""
from app.services.trade_helpers import calculate_trade_costs
for market, tax in (("KOSPI", settings.KR_SELL_TAX_RATE_KOSPI),
("KOSDAQ", settings.KR_SELL_TAX_RATE_KOSDAQ)):
for e, x, q in ((10_000, 10_100, 10), (53_400, 52_100, 7), (1_234, 1_250, 333)):
assert round_trip_costs(e, x, q, settings.KR_COMMISSION_RATE, tax) == \
calculate_trade_costs(e, x, q, True, market)
@pytest.mark.asyncio
async def test_exit_records_net_pnl_and_feeds_daily_pnl(dt_env):
entry, exit_, qty = 10_000.0, 10_100.0, 10
day_trading_state.positions["333333"] = _pos(entry, qty)
with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
res = await es._execute_exit("333333", day_trading_state.positions["333333"], exit_, "트레일링 스탑")
net, cost = _expected_net(entry, exit_, qty)
rec = dt_env["history"][-1]
assert rec["net_profit_loss"] == pytest.approx(net)
assert rec["commission"] == pytest.approx(cost)
assert rec["profit_loss_rate"] == pytest.approx(net / (entry * qty) * 100)
assert rec["strategy_type"] == "daytrading"
assert day_trading_state.daily_pnl == pytest.approx(net)
assert res["gross_profit_loss"] == pytest.approx((exit_ - entry) * qty)
@pytest.mark.asyncio
async def test_breakeven_exit_counts_as_loss_after_costs(dt_env):
"""총손익 0 이어도 순손익은 비용만큼 음수 → loss_tracker 손실, 당일 연패 +1 (의도된 보수화)."""
day_trading_state.positions["444444"] = _pos()
rec = AsyncMock()
with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
await es._execute_exit("444444", day_trading_state.positions["444444"], 10_000.0, "EOD 강제 청산")
rec.assert_awaited_once_with("444444", True)
assert day_trading_state.daily_pnl < 0
def test_daily_loss_limit_fires_earlier_with_net_pnl():
"""[의도 고정] 총손익으로는 한도 직전이던 손실이 순손익으로는 한도를 넘는다."""
total_assets = 10_000_000.0
limit_amt = abs(dts._get_daily_loss_limit()) * total_assets
entry, qty = 10_000.0, 100
# 총손실이 한도보다 1원 작게: (exit-entry)*qty = -(limit_amt-1)
exit_ = entry - (limit_amt - 1) / qty
gross = (exit_ - entry) * qty
net = net_realized_pnl(entry, exit_, qty, settings.KR_COMMISSION_RATE,
settings.KR_SELL_TAX_RATE_KOSPI)["net_profit_loss"]
assert not dts.is_daily_loss_exceeded(total_assets, gross)
assert dts.is_daily_loss_exceeded(total_assets, net)
@pytest.mark.asyncio
async def test_stale_exit_net_pnl_excluded_from_daily(dt_env):
entry, exit_, qty = 10_000.0, 9_500.0, 10
pos = _pos(entry, qty)
before = es.get_stale_realized_pnl()
with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
await es._execute_exit_stale("555555", pos, exit_, "전일 미청산 포지션 강제 청산")
net, _ = _expected_net(entry, exit_, qty)
assert day_trading_state.daily_pnl == 0.0
assert es.get_stale_realized_pnl() - before == pytest.approx(net)
# ── 2b. 체결가 확인 ────────────────────────────────────────────────────────
def test_parse_fill_row_matches_zero_padded_odno():
rows = [{"odno": "0000000777", "tot_ccld_qty": "5", "avg_prvs": "10050"},
{"odno": "0000123", "tot_ccld_qty": "10", "avg_prvs": "0", "tot_ccld_amt": "100700"}]
assert fr.parse_fill_row(rows, "0000123") == (10, 10070.0)
assert fr.parse_fill_row(rows, "777") == (5, 10050.0)
assert fr.parse_fill_row(rows, "888") is None
assert fr.parse_fill_row([{"odno": "1", "tot_ccld_qty": "0", "avg_prvs": "0"}], "1") is None
@pytest.mark.asyncio
async def test_fetch_fill_timeout_returns_none(monkeypatch, caplog):
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0)
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_TIMEOUT_SEC", 0.05)
async def _slow(*a, **k):
await asyncio.sleep(5) # rate-limit sleep 모사
return []
with patch.object(fr.korea_investment_api, "get_todays_orders", _slow):
assert await fr.fetch_fill("000000", "1", "SELL") is None
assert any("시간초과" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_schedule_disabled_is_noop():
# conftest 가 유닛 테스트 기본값으로 OFF
assert settings.DAY_TRADE_FILL_CHECK_ENABLED is False
assert fr.schedule("000000", "1", "BUY", _boom("on_fill")) is None
@pytest.fixture
def fill_on(monkeypatch):
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True)
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0)
monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0)
@pytest.mark.asyncio
async def test_sell_fill_reconcile_adjusts_daily_pnl_and_history(dt_env, fill_on):
entry, signal, fill, qty = 10_000.0, 10_100.0, 10_080.0, 10
day_trading_state.positions["666666"] = _pos(entry, qty)
rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
await es._execute_exit("666666", day_trading_state.positions["666666"], signal, "트레일링 스탑")
await asyncio.gather(*list(fr._pending_tasks))
net_fill, cost_fill = _expected_net(entry, fill, qty)
assert day_trading_state.daily_pnl == pytest.approx(net_fill)
oid, upd = dt_env["updates"][-1]
assert oid == "0000123"
assert float(upd["executed_price"]) == pytest.approx(fill)
assert float(upd["net_profit_loss"]) == pytest.approx(net_fill)
assert float(upd["commission"]) == pytest.approx(cost_fill)
assert float(upd["slippage"]) == pytest.approx(round((fill - signal) / signal * 100, 4))
@pytest.mark.asyncio
async def test_sell_fill_reconcile_stale_goes_to_stale_bucket(dt_env, fill_on):
entry, signal, fill, qty = 10_000.0, 9_500.0, 9_450.0, 10
rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}]
before = es.get_stale_realized_pnl()
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
await es._execute_exit_stale("777777", _pos(entry, qty), signal, "전일 미청산 포지션 강제 청산")
await asyncio.gather(*list(fr._pending_tasks))
net_fill, _ = _expected_net(entry, fill, qty)
assert day_trading_state.daily_pnl == 0.0
assert es.get_stale_realized_pnl() - before == pytest.approx(net_fill)
@pytest.mark.asyncio
async def test_buy_fill_reconcile_updates_entry_price(dt_env, fill_on):
day_trading_state.positions["888888"] = _pos(10_000.0, 10, order_id="0000456")
rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
ens._schedule_buy_fill_reconcile("888888", "0000456", 10_000.0, 10)
await asyncio.gather(*list(fr._pending_tasks))
assert day_trading_state.positions["888888"]["entry_price"] == pytest.approx(10_030.0)
assert dt_env["upserts"][-1] == {"stock_code": "888888", "strategy_name": "day_trade",
"entry_price": 10_030.0}
oid, upd = dt_env["updates"][-1]
assert oid == "0000456" and float(upd["executed_price"]) == pytest.approx(10_030.0)
@pytest.mark.asyncio
async def test_buy_fill_reconcile_skips_replaced_position(dt_env, fill_on):
"""다른 주문으로 교체된 포지션의 진입가는 건드리지 않는다(원장만 보정)."""
day_trading_state.positions["999999"] = _pos(10_000.0, 10, order_id="OTHER")
rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
ens._schedule_buy_fill_reconcile("999999", "0000456", 10_000.0, 10)
await asyncio.gather(*list(fr._pending_tasks))
assert day_trading_state.positions["999999"]["entry_price"] == 10_000.0
assert dt_env["upserts"] == []
assert len(dt_env["updates"]) == 1
@pytest.mark.asyncio
async def test_fill_check_failure_keeps_signal_price(dt_env, fill_on):
"""체결 미확인이면 원장·당일 손익을 건드리지 않는다(신호가 유지)."""
day_trading_state.positions["121212"] = _pos()
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])), \
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()):
await es._execute_exit("121212", day_trading_state.positions["121212"], 10_100.0, "트레일링 스탑")
pnl_after_exit = day_trading_state.daily_pnl
await asyncio.gather(*list(fr._pending_tasks))
assert day_trading_state.daily_pnl == pnl_after_exit
assert dt_env["updates"] == []
# ── 3. strategy_type ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_legacy_record_trade_sets_daytrading_strategy_type():
from app.api.endpoints import day_trading as dt_api
cap = AsyncMock()
with patch("app.services.db_worker.enqueue_trade_history", cap):
await dt_api._record_trade(None, "005930", "삼성전자", "SELL", 70_000.0, 1, "EOD_flat", -100.0)
assert cap.await_args.args[0]["strategy_type"] == "daytrading"

View File

@@ -0,0 +1,411 @@
"""[2026-10-01] get_order_execution 경로 결함 수정 + 처음 살아나는 _verify_and_sync_execution 분기.
결함: 국내 체결조회가 존재하지 않는 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출해
체결 확인이 한 번도 성공하지 못했다(실패는 debug 로 삼키거나 4xx 는 로그조차 없음).
검증 방식 — KIS 는 conftest 가 클래스 메서드 단위로 차단한다. 여기서는 그보다 아래인
**HTTP 계층**(`_get_http_client().request`)을 인스턴스 패치로 가로챈다. 요청 구성 검사는
예외 발생형 목(인자 캡처 후 raise)으로, 응답 파싱은 가짜 응답으로 한다.
토큰(`_get_access_token`)은 conftest 차단을 인스턴스 패치로 덮어 고정 토큰을 준다.
"""
import asyncio
import logging
from unittest.mock import AsyncMock, patch
import pytest
from app.services import korea_investment_api as kis_mod
from app.services import trade_executor as te
from app.services.korea_investment_api import (
CircuitBreaker,
DAILY_CCLD_ENDPOINT,
parse_order_execution_rows,
)
# 모듈 싱글톤(`kis_mod.korea_investment_api`)은 쓰지 않는다 — test_api_endpoints.py 가 수집 시점에
# MagicMock 으로 바꿔 두고 되돌리지 않는다(세션 전체 누수). 테스트마다 새 인스턴스를 만든다.
# conftest 는 테스트 실행 시점에 클래스 메서드를 차단 스텁으로 바꾼다. 수집(import) 시점의
# 원본을 잡아 두고, HTTP 계층만 목킹한 상태에서 **실제 공개 메서드**를 검증한다.
_ORIG_GET_TODAYS = kis_mod.KoreaInvestmentAPI.get_todays_orders
_ORIG_GET_EXEC = kis_mod.KoreaInvestmentAPI.get_order_execution
class _HttpCaptured(Exception):
"""요청 인자를 캡처한 뒤 던지는 예외 — 실제 전송이 일어나지 않았음을 보장."""
class _Resp:
def __init__(self, body: dict, headers: dict | None = None, status: int = 200):
self._body = body
self.headers = headers or {}
self.status_code = status
self.text = ""
def json(self):
return self._body
class _FakeClient:
"""httpx.AsyncClient.request 대체. responses 가 비면 캡처 예외를 던진다."""
def __init__(self, responses=None):
self.calls = []
self.responses = list(responses or [])
self.is_closed = False
async def request(self, method, url, headers=None, timeout=None, **kwargs):
self.calls.append({"method": method, "url": url, "headers": dict(headers or {}), **kwargs})
if not self.responses:
raise _HttpCaptured(url)
return self.responses.pop(0)
class _Api:
"""HTTP 계층만 가로챈 새 KoreaInvestmentAPI 인스턴스 (토큰 캐시 파일·네트워크 미사용)."""
def __init__(self, client: _FakeClient, base_url: str):
api = object.__new__(kis_mod.KoreaInvestmentAPI)
api.base_url = base_url
api.app_key = "TEST_APP_KEY"
api.app_secret = "TEST_APP_SECRET"
api.account_number = "1234567801"
api.access_token = None
api.token_expires_at = None
api.last_request_time = None
api._throttle_lock = asyncio.Lock()
api.min_request_interval = 0.0
api.max_retries = 0
api.retry_delay = 0.0
api.retry_backoff = 1.0
api.timeout = 5.0
api.semaphore = asyncio.Semaphore(5)
api.monitor = kis_mod.APICallMonitor(window_size=10)
api.circuit_breaker = CircuitBreaker(failure_threshold=99, recovery_timeout=1)
# conftest 의 클래스 차단(_get_access_token)을 인스턴스 속성으로 덮는다
api._get_access_token = AsyncMock(return_value="TEST_TOKEN")
api._get_http_client = AsyncMock(return_value=client)
self.api = api
def __enter__(self):
return self.api
def __exit__(self, *exc):
return False
REAL = "https://openapi.koreainvestment.com:9443"
VIRTUAL = "https://openapivts.koreainvestment.com:29443"
# ─────────────────────────────────────────────────────────────────────────────
# 1. 요청 구성 (URL·TR_ID·파라미터) — 예외 발생형 HTTP 목으로 인자만 검사
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@pytest.mark.parametrize("base_url,expected_tr", [(REAL, "TTTC0081R"), (VIRTUAL, "VTTC0081R")])
async def test_order_execution_request_uses_daily_ccld(base_url, expected_tr, caplog):
client = _FakeClient()
with _Api(client, base_url) as API, caplog.at_level(logging.WARNING):
out = await API._get_order_execution_domestic_impl("0000117057")
assert len(client.calls) == 1, "예외 발생 시 대체 TR 재시도·내부 재시도 없이 1회만"
call = client.calls[0]
assert call["method"] == "GET"
assert call["url"] == f"{base_url}{DAILY_CCLD_ENDPOINT}"
assert call["url"].endswith("/uapi/domestic-stock/v1/trading/inquire-daily-ccld")
assert "inquire-ccnl" not in call["url"]
assert call["headers"]["tr_id"] == expected_tr
assert "tr_cont" not in call["headers"], "첫 페이지는 tr_cont 헤더 없음"
p = call["params"]
assert p["CANO"] == "12345678" and p["ACNT_PRDT_CD"] == "01"
assert p["INQR_STRT_DT"] == p["INQR_END_DT"] and len(p["INQR_STRT_DT"]) == 8
assert p["ODNO"] == "0000117057"
assert p["SLL_BUY_DVSN_CD"] == "00" and p["CCLD_DVSN"] == "00"
assert p["INQR_DVSN"] == "00" and p["INQR_DVSN_3"] == "00"
assert p["CTX_AREA_FK100"] == "" and p["CTX_AREA_NK100"] == ""
assert p["EXCG_ID_DVSN_CD"] == "KRX"
# 실패 → 기존 계약대로 filled_qty=0 + warning(debug 금지)
assert out["filled_qty"] == 0 and out["filled_price"] == 0.0 and out["ok"] is False
assert any("[get_order_execution]" in r.message and r.levelno >= logging.WARNING
for r in caplog.records)
@pytest.mark.asyncio
async def test_public_get_order_execution_hits_daily_ccld():
"""공개 메서드(원본) 경유로도 inquire-daily-ccld 를 부르고 기존 2키 계약을 지킨다."""
body = {"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3",
"avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]}
client = _FakeClient([_Resp(body)])
with _Api(client, REAL) as API:
out = await _ORIG_GET_EXEC(API, "0000000042", True)
assert client.calls[0]["url"].endswith("/trading/inquire-daily-ccld")
assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0
@pytest.mark.asyncio
async def test_get_todays_orders_request_and_warning(caplog):
"""get_todays_orders 도 같은 요청 구성(ODNO 공란, PDNO=종목)이며 실패는 warning."""
client = _FakeClient()
with _Api(client, VIRTUAL) as API, caplog.at_level(logging.WARNING):
rows = await _ORIG_GET_TODAYS(API, "005930")
assert rows == []
call = client.calls[0]
assert call["url"] == f"{VIRTUAL}{DAILY_CCLD_ENDPOINT}"
assert call["headers"]["tr_id"] == "VTTC0081R"
assert call["params"]["PDNO"] == "005930" and call["params"]["ODNO"] == ""
assert any("[get_todays_orders]" in r.message and r.levelno >= logging.WARNING
for r in caplog.records)
_, p_exec = API._daily_ccld_request_v2(order_id="1")
assert set(call["params"]) == set(p_exec), "두 메서드의 파라미터 키 집합이 같아야 한다"
@pytest.mark.asyncio
async def test_get_todays_orders_success_returns_rows():
rows = [{"odno": "1", "pdno": "005930"}]
client = _FakeClient([_Resp({"rt_cd": "0", "output1": rows})])
with _Api(client, REAL) as API:
assert await _ORIG_GET_TODAYS(API, "005930") == rows
@pytest.mark.asyncio
async def test_legacy_tr_fallback_on_error_rt_cd():
"""현행 TR 이 rt_cd!=0 이면 구 TR(TTTC8001R) 1회 재시도, 성공하면 이후 구 TR 우선."""
ok = {"rt_cd": "0", "output1": [{"odno": "0000000777", "tot_ccld_qty": "5",
"avg_prvs": "10100", "ord_qty": "5", "rmn_qty": "0"}]}
client = _FakeClient([
_Resp({"rt_cd": "1", "msg_cd": "OPSQ0002", "msg1": "없는 서비스 코드"}),
_Resp(ok),
])
with _Api(client, REAL) as API:
out = await API._get_order_execution_domestic_impl("777")
assert [c["headers"]["tr_id"] for c in client.calls] == ["TTTC0081R", "TTTC8001R"]
assert out["filled_qty"] == 5 and out["filled_price"] == 10100.0 and out["found"]
# 다음 호출은 구 TR 부터
client.responses.append(_Resp(ok))
await API._get_order_execution_domestic_impl("777")
assert client.calls[-1]["headers"]["tr_id"] == "TTTC8001R"
@pytest.mark.asyncio
async def test_pagination_uses_tr_cont_n_and_ctx_keys():
page1 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000001", "tot_ccld_qty": "1"}],
"ctx_area_fk100": "FK1", "ctx_area_nk100": "NK1"}, headers={"tr_cont": "M"})
page2 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3",
"avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]},
headers={"tr_cont": "D"})
client = _FakeClient([page1, page2])
with _Api(client, VIRTUAL) as API:
out = await API._get_order_execution_domestic_impl("0000000042")
assert len(client.calls) == 2
assert client.calls[1]["headers"]["tr_cont"] == "N"
assert client.calls[1]["params"]["CTX_AREA_FK100"] == "FK1"
assert client.calls[1]["params"]["CTX_AREA_NK100"] == "NK1"
assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0
@pytest.mark.asyncio
async def test_404_like_response_logs_warning(caplog):
"""옛 경로처럼 4xx 가 돌아와도 로그 없이 0 을 돌려주던 동작 → warning."""
client = _FakeClient([_Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404),
_Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404)])
with _Api(client, REAL) as API, caplog.at_level(logging.WARNING):
out = await API._get_order_execution_domestic_impl("123")
assert out["filled_qty"] == 0 and out["ok"] is False
assert any("체결조회 실패" in r.message for r in caplog.records)
# ─────────────────────────────────────────────────────────────────────────────
# 2. 응답 파싱 (순수 함수)
# ─────────────────────────────────────────────────────────────────────────────
def test_parse_full_fill_with_zero_padding():
rows = [{"odno": "0000117057", "tot_ccld_qty": "10", "avg_prvs": "71200",
"ord_qty": "10", "rmn_qty": "0", "cncl_yn": "N"}]
out = parse_order_execution_rows(rows, "117057")
assert out == {"filled_qty": 10, "filled_price": 71200.0, "ordered_qty": 10,
"remaining_qty": 0, "cancelled": False,
"cancel_confirmed_qty": None, "rejected_qty": None}
def test_parse_cancel_and_reject_quantities():
rows = [{"odno": "3", "tot_ccld_qty": "4", "avg_prvs": "100", "ord_qty": "10",
"rmn_qty": "0", "cnc_cfrm_qty": "5", "rjct_qty": "1", "cncl_yn": "N"}]
out = parse_order_execution_rows(rows, "3")
assert out["cancel_confirmed_qty"] == 5 and out["rejected_qty"] == 1
assert out["cancelled"] is False
def test_parse_partial_and_avg_fallback():
rows = [{"odno": "0000000099", "tot_ccld_qty": "4", "avg_prvs": "0", "tot_ccld_amt": "40400",
"ord_qty": "10", "rmn_qty": "6"}]
out = parse_order_execution_rows(rows, "0000000099")
assert out["filled_qty"] == 4 and out["filled_price"] == 10100.0
assert out["remaining_qty"] == 6
def test_parse_unfilled_and_missing_remaining_field():
out = parse_order_execution_rows([{"odno": "5", "tot_ccld_qty": "0", "avg_prvs": "0",
"ord_qty": "3"}], "5")
assert out["filled_qty"] == 0 and out["filled_price"] == 0.0
assert out["remaining_qty"] is None, "필드 없음은 0(소멸)이 아니라 None(모름)"
def test_parse_empty_or_no_match():
assert parse_order_execution_rows([], "1") is None
assert parse_order_execution_rows(None, "1") is None
assert parse_order_execution_rows([{"odno": "2", "tot_ccld_qty": "1"}], "1") is None
assert parse_order_execution_rows([{"odno": "1"}], "") is None
# 첫 행이 아니라 주문번호가 일치하는 행을 고른다 (옛 구현은 output[0] 고정)
rows = [{"odno": "8", "tot_ccld_qty": "9", "avg_prvs": "1"},
{"odno": "7", "tot_ccld_qty": "2", "avg_prvs": "300", "ord_qty": "2", "rmn_qty": "0"}]
assert parse_order_execution_rows(rows, "7")["filled_qty"] == 2
@pytest.mark.asyncio
async def test_overseas_returns_legacy_contract_without_http():
client = _FakeClient()
with _Api(client, REAL) as API:
out = await _ORIG_GET_EXEC(API, "123", False)
assert out == {"filled_qty": 0, "filled_price": 0.0} and client.calls == []
# ─────────────────────────────────────────────────────────────────────────────
# 3. _verify_and_sync_execution — 처음 살아나는 분기의 동작
# ─────────────────────────────────────────────────────────────────────────────
class _ReorderCalled(BaseException):
"""자동 재주문 경로가 호출됨 — BaseException 이라 `except Exception` 에 삼켜지지 않는다."""
def _reorder_forbidden(*args, **kwargs):
raise _ReorderCalled(f"_reorder_unfilled 호출됨: {kwargs}")
class _VerifyHarness:
def __init__(self, exec_results):
self.exec_mock = AsyncMock(side_effect=list(exec_results))
self.upd_by_order = AsyncMock()
self.upd_by_id = AsyncMock()
self.sync = AsyncMock()
self.reorder = _reorder_forbidden
self._real_sleep = asyncio.sleep
async def _fast_sleep(self, *_a, **_k):
await self._real_sleep(0)
async def run(self, ordered=10, order_type="BUY", order_price=10000.0):
with patch.object(te.korea_investment_api, "get_order_execution", self.exec_mock), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", self.upd_by_order), \
patch("app.services.db_worker.enqueue_update_trade_history", self.upd_by_id), \
patch.object(te, "_delayed_sync_portfolio", self.sync), \
patch.object(te, "_reorder_unfilled", self.reorder), \
patch.object(te.asyncio, "sleep", self._fast_sleep):
await te._verify_and_sync_execution(
order_id="0000000042", stock_code="005930", stock_name="삼성전자",
ordered_quantity=ordered, order_price=order_price, order_type=order_type,
is_domestic=True, exchange_code="KRX", db_trade_id=0, delay=0,
)
await self._real_sleep(0) # create_task 로 띄운 재주문 태스크 실행 기회
def _ex(filled, price=10050.0, remaining=None, cancelled=False, ordered=10):
return {"filled_qty": filled, "filled_price": price if filled else 0.0, "found": True, "ok": True,
"ordered_qty": ordered, "remaining_qty": remaining, "cancelled": cancelled}
@pytest.mark.asyncio
async def test_verify_full_fill_updates_completed_once():
h = _VerifyHarness([_ex(10, remaining=0)])
await h.run()
assert h.exec_mock.await_count == 1
h.upd_by_order.assert_awaited_once()
order_id, fields = h.upd_by_order.await_args.args
assert order_id == "0000000042"
assert fields["status"] == "COMPLETED" and fields["executed_quantity"] == 10
assert float(fields["executed_price"]) == 10050.0
assert float(fields["slippage"]) == pytest.approx(0.5)
h.sync.assert_awaited_once()
@pytest.mark.asyncio
async def test_verify_partial_with_live_remainder_never_reorders():
"""잔량이 KIS 에 살아 있으면 재주문 = 중복 주문. 옛 코드는 첫 부분체결에서 바로 재주문했다."""
h = _VerifyHarness([_ex(4, remaining=6)] * 5)
await h.run()
assert h.exec_mock.await_count == 5, "부분체결 첫 관측에서 멈추지 않고 계속 폴링"
h.upd_by_order.assert_not_called()
h.sync.assert_awaited_once()
@pytest.mark.asyncio
async def test_verify_partial_then_full_within_polls():
h = _VerifyHarness([_ex(4, remaining=6), _ex(10, remaining=0)])
await h.run()
assert h.exec_mock.await_count == 2
fields = h.upd_by_order.await_args.args[1]
assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED"
@pytest.mark.asyncio
async def test_verify_partial_unknown_remainder_is_treated_as_live():
h = _VerifyHarness([_ex(4, remaining=None)] * 5)
await h.run()
h.upd_by_order.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("order_type", ["BUY", "SELL"])
async def test_verify_partial_dead_remainder_records_and_never_reorders(order_type, caplog):
"""잔량 소멸(취소·거부·만료) — 체결분만 기록, 자동 재주문 금지(가드 전부 우회하므로), 수동 확인 warning."""
res = _ex(4, remaining=0)
res.update({"cancel_confirmed_qty": 6, "rejected_qty": 0})
h = _VerifyHarness([res])
with caplog.at_level(logging.WARNING):
await h.run(order_type=order_type) # 재주문 호출 시 _ReorderCalled 로 테스트 실패
fields = h.upd_by_order.await_args.args[1]
assert fields["executed_quantity"] == 4
assert fields["status"] == "COMPLETED", "PARTIAL 은 재진입 가드·손익 집계에서 빠진다"
msgs = [r.message for r in caplog.records if "[PARTIAL_FILL]" in r.message]
assert msgs, "잔량 소멸 warning 누락"
m = msgs[-1]
for frag in ("005930", "0000000042", order_type, "4/10", "취소확인=6", "거부=0", "수동 확인"):
assert frag in m, (frag, m)
h.sync.assert_awaited_once()
@pytest.mark.asyncio
async def test_verify_partial_cancelled_does_not_reorder():
h = _VerifyHarness([_ex(4, remaining=0, cancelled=True)])
await h.run()
h.upd_by_order.assert_awaited_once()
def test_verify_source_has_no_reorder_call():
"""정적 고정 — _verify_and_sync_execution 본문에서 _reorder_unfilled 호출 금지."""
import inspect
body = inspect.getsource(te._verify_and_sync_execution).split('"""', 2)[2]
assert "_reorder_unfilled(" not in body
@pytest.mark.asyncio
async def test_verify_unfilled_no_db_write_and_warns(caplog):
h = _VerifyHarness([_ex(0, remaining=10)] * 5)
with caplog.at_level(logging.WARNING):
await h.run()
h.upd_by_order.assert_not_called()
h.sync.assert_awaited_once()
assert any("체결 미확인" in r.message for r in caplog.records)
@pytest.mark.asyncio
async def test_verify_overfill_is_clamped():
h = _VerifyHarness([_ex(15, remaining=0)])
await h.run()
assert h.upd_by_order.await_args.args[1]["executed_quantity"] == 10
@pytest.mark.asyncio
async def test_verify_legacy_two_key_result_still_works():
"""get_order_execution 의 기존 2키 계약만 주는 목(해외/옛 호출부)과도 동작."""
h = _VerifyHarness([{"filled_qty": 10, "filled_price": 9950.0}])
await h.run(order_type="SELL")
fields = h.upd_by_order.await_args.args[1]
assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED"

View File

@@ -0,0 +1,647 @@
"""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]