- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강 - trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성) - korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계) - swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소) - swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램 - daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거 - main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC) - 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경) - 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기 - CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기) - 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
404 lines
17 KiB
Python
404 lines
17 KiB
Python
"""하드닝 9회차 그룹 C(데이트레이딩) 회귀 테스트.
|
|
|
|
C-1 결과 불명 DT 매도 + ActiveTrade 없는 수동·HTS 보유분 → 수동 물량 재매도 방지(v3 판정)
|
|
C-2 기동 복원 검증이 주문가능수량·당일 PENDING DT 매도를 본다(v3)
|
|
C-3 잔고 조회 직후 일일 손실 한도 재확인
|
|
"""
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.services.daytrading import entry_service as ens
|
|
from app.services.daytrading import exit_service as es
|
|
from app.services.daytrading import position_manager as pm
|
|
from app.services.daytrading.position_manager import DayTradingState, day_trading_state
|
|
|
|
CODE = "005930"
|
|
|
|
|
|
class _MustNotCall(BaseException):
|
|
"""호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException."""
|
|
|
|
|
|
class OrderReached(BaseException):
|
|
"""safe_place_order 도달 — kwargs 를 실어 수량을 검증한다(값 반환 스텁 대신 예외형)."""
|
|
|
|
|
|
async def _order_reached(*a, **k):
|
|
raise OrderReached(k)
|
|
|
|
|
|
def _raise(name):
|
|
async def _f(*a, **k):
|
|
raise _MustNotCall(name)
|
|
return _f
|
|
|
|
|
|
class _Result:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
def scalars(self):
|
|
return self
|
|
|
|
def all(self):
|
|
return list(self._rows)
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
async def execute(self, *_a, **_k):
|
|
return _Result(self._rows)
|
|
|
|
|
|
def _th(ot, st, q, code=CODE):
|
|
return SimpleNamespace(stock_code=code, order_type=ot, status=st, executed_quantity=q,
|
|
order_quantity=q, strategy_type="daytrading", created_at=datetime.now())
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate(monkeypatch):
|
|
import app.core.redis as _r
|
|
es._unresolved_exit_unknown.clear()
|
|
es._unknown_lookup_failures.clear()
|
|
monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 10_000)
|
|
monkeypatch.setattr(_r, "setex_json", AsyncMock(return_value=True))
|
|
monkeypatch.setattr(_r, "get_json", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(pm, "_notify_daily_state_impl", AsyncMock(return_value=None))
|
|
yield
|
|
es._unresolved_exit_unknown.clear()
|
|
es._unknown_lookup_failures.clear()
|
|
|
|
|
|
def _kis(monkeypatch, hldg, sellable, nd=0, orders=(), ledger=None):
|
|
async def _h():
|
|
return ({CODE: {"hldg": hldg, "sellable": sellable}}, True)
|
|
|
|
async def _nd(code):
|
|
return nd
|
|
|
|
async def _o(code):
|
|
return None if orders is None else list(orders)
|
|
monkeypatch.setattr(es, "_fetch_kis_holdings_v3_impl", _h)
|
|
monkeypatch.setattr(es, "_fetch_non_dt_active_qty_impl", _nd)
|
|
monkeypatch.setattr(es, "_lookup_todays_orders_impl", _raise("v1 주문내역(실패=[]) 사용 금지"))
|
|
monkeypatch.setattr(es, "_lookup_todays_orders_v2_impl", _o)
|
|
if ledger is not None:
|
|
monkeypatch.setattr(es, "_fetch_dt_ledger_net_qty_impl", ledger)
|
|
|
|
|
|
async def _ledger(n):
|
|
async def _f(code):
|
|
return n
|
|
return _f
|
|
|
|
|
|
# ═══ C-1 ═════════════════════════════════════════════════════════════════════
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_manual_shares_after_filled_unknown_sell_not_resold(monkeypatch):
|
|
"""dt.md 재현: DT 10주 불명 매도(실체결) + HTS 5주(ActiveTrade 없음) → 재매도 0."""
|
|
_kis(monkeypatch, 5, 5, ledger=_raise("원장 조회 전에 보류되어야 한다"))
|
|
st, resp, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, resp, cap) == ("ambiguous", None, 0)
|
|
|
|
# 재시도 경로 종단: 주문이 나가지 않고 보류가 유지된다
|
|
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
|
|
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
|
|
}
|
|
with patch("app.services.trade_executor.safe_place_order", _order_reached):
|
|
res = await es._place_and_finalize_exit_impl(
|
|
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
|
|
assert res is None
|
|
assert (es._today_key(), CODE) in es._unresolved_exit_unknown
|
|
assert es._last_exit_order_outcome[CODE] == "unknown"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_genuine_not_accepted_still_resells(monkeypatch):
|
|
"""정상 not_accepted(원장 순체결 10, 잔고 10) → 10주 재매도."""
|
|
_kis(monkeypatch, 10, 10, ledger=await _ledger(10))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, cap) == ("not_accepted", 10)
|
|
|
|
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
|
|
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
|
|
}
|
|
with patch("app.services.trade_executor.safe_place_order", _order_reached):
|
|
with pytest.raises(OrderReached) as ei:
|
|
await es._place_and_finalize_exit_impl(
|
|
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
|
|
assert ei.value.args[0]["quantity"] == 10
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_ledger_caps_resell(monkeypatch):
|
|
"""잔고상 DT 몫이 원장보다 크면(ActiveTrade 없는 수동분) 원장 순체결로 상한."""
|
|
_kis(monkeypatch, 15, 15, ledger=await _ledger(10))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, cap) == ("not_accepted", 10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_ledger_failure_holds(monkeypatch):
|
|
"""원장 조회 예외 → None → ledger_failed(재매도 보류, fail-closed)."""
|
|
def _boom():
|
|
raise RuntimeError("db down")
|
|
monkeypatch.setattr("app.core.database.AsyncSessionLocal", _boom)
|
|
assert await es._fetch_dt_ledger_net_qty_impl(CODE) is None
|
|
|
|
_kis(monkeypatch, 10, 10)
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, cap) == ("ledger_failed", 0)
|
|
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
|
|
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
|
|
}
|
|
with patch("app.services.trade_executor.safe_place_order", _order_reached):
|
|
res = await es._place_and_finalize_exit_impl(
|
|
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
|
|
assert res is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_ledger_nonpositive_holds(monkeypatch):
|
|
"""원장 순체결 ≤ 0 → 재매도 보류(과다매도보다 보류, §3.5). 재시도 종단에서도 주문 0."""
|
|
_kis(monkeypatch, 10, 10, ledger=await _ledger(0))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, cap) == ("ledger_nonpositive", 0)
|
|
es._unresolved_exit_unknown[(es._today_key(), CODE)] = {
|
|
"qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0,
|
|
}
|
|
with patch("app.services.trade_executor.safe_place_order", _order_reached):
|
|
res = await es._place_and_finalize_exit_impl(
|
|
CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False)
|
|
assert res is None and (es._today_key(), CODE) in es._unresolved_exit_unknown
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_orders_lookup_none_holds(monkeypatch):
|
|
"""주문내역 v2 조회 실패(None) → 보류(원장까지 가지 않는다)."""
|
|
_kis(monkeypatch, 10, 10, orders=None, ledger=_raise("주문내역 실패 시 원장 불필요"))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, cap) == ("orders_failed", 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_v_repro_manual_ge_q_filled_not_resold(monkeypatch):
|
|
"""v_repro_dt: 수동 M=10 ≥ q=10, DT 10주 체결(잔고 온전해 보임). 주문내역에 등록 이후 행 → 접수 확정."""
|
|
now = es._kst_now()
|
|
row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10",
|
|
"rjct_qty": "0", "cncl_yn": "N", "odno": "0000999", "ord_tmd": now.strftime("%H%M%S")}
|
|
_kis(monkeypatch, 10, 10, orders=[row], ledger=_raise("접수 확정 시 원장 불필요"))
|
|
st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10)
|
|
assert st == "accepted" and resp["output"]["ODNO"] == "0000999"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_v_repro_rows_invisible_ledger_zero_holds(monkeypatch):
|
|
"""v_repro_dt 원본(주문내역 [] + 원장 0) → 재매도 0(보류)."""
|
|
_kis(monkeypatch, 10, 10, orders=[], ledger=await _ledger(0))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, None, 10)
|
|
assert (st, cap) == ("ledger_nonpositive", 0)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_empty_orders_positive_ledger_capped_by_q(monkeypatch):
|
|
"""주문내역 [](KIS 미접수 실증) + 원장 > 0 → 재매도, 상한 min(DT 매도가능, 원장, q)."""
|
|
_kis(monkeypatch, 15, 15, orders=[], ledger=await _ledger(20))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 5, es._kst_now(), 10)
|
|
assert (st, cap) == ("not_accepted", 5)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_row_before_registration_or_without_time_ignored(monkeypatch):
|
|
"""등록 시각(−허용 오차) 이전 행·주문시각 없는 행은 같은 수량이어도 접수로 보지 않는다(r5 L-1 유지)."""
|
|
from datetime import timedelta
|
|
now = es._kst_now()
|
|
early = (now - timedelta(seconds=float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) + 60)).strftime("%H%M%S")
|
|
base = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10",
|
|
"rjct_qty": "0", "cncl_yn": "N"}
|
|
rows = [dict(base, odno="0000111", ord_tmd=early), dict(base, odno="0000222")]
|
|
_kis(monkeypatch, 10, 10, orders=rows, ledger=await _ledger(10))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10)
|
|
assert (st, cap) == ("not_accepted", 10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_pending_other_order_reduces_sellable_still_resells(monkeypatch):
|
|
"""보유 30·주문가능 26·비DT 20: 보유 기준 DT 몫 10 온전 → v2 대로 6주 재매도(보류 아님)."""
|
|
_kis(monkeypatch, 30, 26, nd=20, ledger=await _ledger(10))
|
|
st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10)
|
|
assert (st, cap) == ("not_accepted", 6)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_matching_order_row_is_accepted(monkeypatch):
|
|
now = es._kst_now()
|
|
row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10",
|
|
"rjct_qty": "0", "cncl_yn": "N", "odno": "0000777", "ord_tmd": now.strftime("%H%M%S")}
|
|
_kis(monkeypatch, 5, 5, orders=[row], ledger=_raise("접수 확정 시 원장 불필요"))
|
|
st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10)
|
|
assert st == "accepted" and resp["output"]["ODNO"] == "0000777"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c1_ledger_query_nets_pending_sell(monkeypatch):
|
|
rows = [_th("BUY", "COMPLETED", 10), _th("SELL", "PENDING", 4), _th("SELL", "FAILED", 6)]
|
|
monkeypatch.setattr("app.core.database.AsyncSessionLocal", lambda: _Session(rows))
|
|
assert await es._fetch_dt_ledger_net_qty_impl(CODE) == 6
|
|
|
|
|
|
# ═══ C-2 ═════════════════════════════════════════════════════════════════════
|
|
|
|
def _restored_state(qty=10):
|
|
st = DayTradingState()
|
|
st.positions[CODE] = {
|
|
"stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0, "stop_loss": 9_800.0,
|
|
"target_price": 10_300.0, "quantity": qty, "highest_price": 10_000.0,
|
|
"entry_time": datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=9, minute=10),
|
|
"reason": "", "order_id": "", "restored_from_db": True,
|
|
}
|
|
return st
|
|
|
|
|
|
def _hold(hldg, sellable):
|
|
async def _f():
|
|
return {CODE: {"hldg": hldg, "sellable": sellable}}
|
|
return _f
|
|
|
|
|
|
def _rows(rows):
|
|
async def _f():
|
|
return rows
|
|
return _f
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c2_sellable_below_hldg_holds_restore():
|
|
st = _restored_state(10)
|
|
out = await st.verify_restored_today_positions_v3(
|
|
session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 0), today_rows_fetcher=_rows([]))
|
|
assert CODE in out["held"] and CODE not in st.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c2_pending_dt_sell_holds_restore():
|
|
st = _restored_state(10)
|
|
out = await st.verify_restored_today_positions_v3(
|
|
session_factory=lambda: _Session([]), holdings_fetcher=_raise("PENDING 보류 후 잔고 조회 불필요"),
|
|
today_rows_fetcher=_rows([_th("SELL", "PENDING", 10)]))
|
|
assert out["held"] == [CODE] and CODE not in st.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c2_normal_restore_kept():
|
|
st = _restored_state(10)
|
|
out = await st.verify_restored_today_positions_v3(
|
|
session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 10),
|
|
today_rows_fetcher=_rows([_th("BUY", "COMPLETED", 10)]))
|
|
assert out["held"] == [] and CODE in st.positions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c2_ledger_failure_holds_when_orphan_detect_enabled(monkeypatch):
|
|
monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True)
|
|
st = _restored_state(10)
|
|
out = await st.verify_restored_today_positions_v3(
|
|
session_factory=lambda: _Session([]), holdings_fetcher=_raise("보류 후 잔고 조회 불필요"),
|
|
today_rows_fetcher=_raise_plain())
|
|
assert out["held"] == [CODE]
|
|
|
|
|
|
def _raise_plain():
|
|
async def _f():
|
|
raise RuntimeError("db down")
|
|
return _f
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c2_intraday_restore_uses_sellable(monkeypatch):
|
|
row = SimpleNamespace(stock_code=CODE, quantity=10)
|
|
monkeypatch.setattr(day_trading_state, "positions", {})
|
|
n = await es._restore_lost_today_positions_v2_impl([row], {CODE: {"hldg": 10, "sellable": 0}}, {})
|
|
assert n == 0 and CODE not in day_trading_state.positions
|
|
|
|
|
|
# ═══ C-3 ═════════════════════════════════════════════════════════════════════
|
|
|
|
TOTAL = 100_000_000.0
|
|
CASH = 50_000_000.0
|
|
|
|
|
|
def _at(hh, mm):
|
|
fake_now = datetime.now(ens._KST).replace(hour=hh, minute=mm, second=0)
|
|
|
|
class _DT(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return fake_now if tz else fake_now.replace(tzinfo=None)
|
|
return _DT
|
|
|
|
|
|
class _Adp:
|
|
fallback_used = False
|
|
daytrading_enabled = True
|
|
trading_mode = "normal"
|
|
|
|
|
|
async def _scan(monkeypatch, daily_pnl):
|
|
captured: list = []
|
|
|
|
async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False):
|
|
captured.append(code)
|
|
return None
|
|
|
|
monkeypatch.setattr(day_trading_state, "positions", {})
|
|
monkeypatch.setattr(day_trading_state, "closed_today", set())
|
|
monkeypatch.setattr(day_trading_state, "last_total_assets", 0.0) # 재기동 직후
|
|
monkeypatch.setattr(day_trading_state, "daily_pnl", daily_pnl)
|
|
monkeypatch.setattr(day_trading_state, "last_reset_date", pm._today_kst()) # 날짜 리셋 방지
|
|
monkeypatch.setattr(day_trading_state, "loss_streak_today", 0, raising=False)
|
|
monkeypatch.setattr(ens, "_non_dt_held_db", set())
|
|
monkeypatch.setattr(ens, "_kis_holdings_snapshot", {})
|
|
monkeypatch.setattr(ens, "_kis_holdings_verified", True)
|
|
monkeypatch.setattr(ens, "datetime", _at(10, 30))
|
|
ens.set_watchlist([{"code": CODE, "name": "삼성"}])
|
|
balance = {"output1": [], "output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}]}
|
|
try:
|
|
with patch.object(ens.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \
|
|
patch.object(ens.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)), \
|
|
patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \
|
|
patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \
|
|
patch.object(ens, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \
|
|
patch.object(ens.korea_investment_api, "get_balance", AsyncMock(return_value=balance)), \
|
|
patch.object(ens.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=CASH)), \
|
|
patch.object(ens, "_prefetch_minute_charts", AsyncMock(return_value=0)), \
|
|
patch.object(ens, "_try_enter", fake_try_enter):
|
|
await ens.scan_and_enter()
|
|
finally:
|
|
ens.set_watchlist([])
|
|
return captured
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c3_loss_limit_rechecked_after_balance(monkeypatch):
|
|
from app.services.day_trading_strategy import _get_daily_loss_limit
|
|
pnl = TOTAL * _get_daily_loss_limit() * 1.5 # 한도 초과 손실
|
|
assert await _scan(monkeypatch, pnl) == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_c3_control_no_loss_reaches_entry(monkeypatch):
|
|
assert await _scan(monkeypatch, 0.0) == [CODE]
|