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:
@@ -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)
|
||||
|
||||
176
backend/tests/test_db_worker_order_id_update_20261001.py
Normal file
176
backend/tests/test_db_worker_order_id_update_20261001.py
Normal 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)
|
||||
338
backend/tests/test_dt_cost_fill_timestop_20261001.py
Normal file
338
backend/tests/test_dt_cost_fill_timestop_20261001.py
Normal 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"
|
||||
411
backend/tests/test_order_execution_daily_ccld_20261001.py
Normal file
411
backend/tests/test_order_execution_daily_ccld_20261001.py
Normal 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"
|
||||
647
backend/tests/test_trade_executor_risk_fixes_20261001.py
Normal file
647
backend/tests/test_trade_executor_risk_fixes_20261001.py
Normal 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]
|
||||
Reference in New Issue
Block a user