fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영

하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 12:01:31 +09:00
parent 0f89a9d678
commit 9fab4018d4
99 changed files with 15712 additions and 1718 deletions

View File

@@ -0,0 +1,524 @@
"""[2026-10-02] 가드 우회 엔드포인트 회귀 테스트 — H-3 / H-4 / H-5 / M-5 / C1 / Low.
원칙: 주문 종단(`trade_executor.safe_place_order`)·KIS 는 conftest 가 예외 발생형으로
막고 있고, 여기서는 필요한 것만 캡처형으로 덮는다. 호출되면 안 되는 경로는 예외 발생형.
기대값은 settings.* 에서 계산한다(하드코딩 금지).
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from app.core.config import settings
class _MustNotCall(BaseException):
pass
def _boom(name):
async def _f(*a, **k):
raise _MustNotCall(f"{name} 가 호출되면 안 된다")
return _f
# ═════════════════════════════════════════════════════════════════════════════
# H-3: POST /recommendations/auto-trade → execute_auto_trade_background 위임
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_h3_auto_trade_blocked_when_disabled(monkeypatch):
from app.api.endpoints import recommendations as rec
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", False)
monkeypatch.setattr(rec, "get_rule_based_recommendations", _boom("추천 조회"))
monkeypatch.setattr(rec, "execute_auto_trade_background", _boom("자동매매"))
with pytest.raises(HTTPException) as ei:
await rec.execute_auto_trading(db=object())
assert ei.value.status_code == 423
@pytest.mark.asyncio
async def test_h3_auto_trade_rejects_when_bg_lock_held(monkeypatch):
from app.api.endpoints import recommendations as rec
from app.services import trade_executor as te
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True)
monkeypatch.setattr(settings, "TRADING_HALTED", False)
monkeypatch.setattr(rec, "get_rule_based_recommendations", _boom("추천 조회"))
await te._auto_trade_bg_lock.acquire()
try:
with pytest.raises(HTTPException) as ei:
await rec.execute_auto_trading(db=object())
assert ei.value.status_code == 409
finally:
te._auto_trade_bg_lock.release()
@pytest.mark.asyncio
async def test_h3_auto_trade_delegates_to_background(monkeypatch):
from app.api.endpoints import recommendations as rec
from app.services import trade_executor as te
monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True)
monkeypatch.setattr(settings, "TRADING_HALTED", False)
payload = {"recommendations_by_rule": [{"rule_name": "r", "stocks": [{"stock_code": "005930"}]}]}
monkeypatch.setattr(rec, "get_rule_based_recommendations", AsyncMock(return_value=payload))
bg = AsyncMock(return_value=[
{"action": "BUY", "success": True}, {"action": "BUY", "success": False},
{"action": "SKIP"},
])
monkeypatch.setattr(rec, "execute_auto_trade_background", bg)
# 직접 종목별 실행 경로가 남아 있으면 즉시 실패
monkeypatch.setattr(te, "execute_auto_trade", _boom("execute_auto_trade(직접)"))
out = await rec.execute_auto_trading(db="DB")
bg.assert_awaited_once_with(payload, "DB")
assert "execute_auto_trade" not in vars(rec), "엔드포인트 모듈이 직접 실행 함수를 import 하고 있다"
assert "진입 1건" in out["message"]
assert len(out["trades"]) == 2
# ═════════════════════════════════════════════════════════════════════════════
# H-5: /us-trading/execute 410, /cycle 플래그 OFF 423
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
@pytest.mark.parametrize("flag", [False, True])
async def test_h5_execute_is_gone_regardless_of_flag(monkeypatch, flag):
from app.api.endpoints import us_trading as us
monkeypatch.setattr(settings, "US_TRADING_ENABLED", flag)
req = us.UsOrderRequest(ticker="AAPL", order_type="buy", quantity=1, price=100.0)
with pytest.raises(HTTPException) as ei:
await us.execute_us_trade(req)
assert ei.value.status_code == 410
@pytest.mark.asyncio
async def test_h5_cycle_blocked_when_flag_off(monkeypatch):
from app.api.endpoints import us_trading as us
import app.services.us_market_scheduler as ums
monkeypatch.setattr(settings, "US_TRADING_ENABLED", False)
monkeypatch.setattr(ums, "us_trade_cycle", _boom("us_trade_cycle"))
with pytest.raises(HTTPException) as ei:
await us.trigger_us_trade_cycle()
assert ei.value.status_code == 423
@pytest.mark.asyncio
async def test_h5_cycle_runs_and_http_exception_passthrough(monkeypatch):
from app.api.endpoints import us_trading as us
import app.services.us_market_scheduler as ums
monkeypatch.setattr(settings, "US_TRADING_ENABLED", True)
monkeypatch.setattr(ums, "us_trade_cycle", AsyncMock(return_value={"ok": 1}))
assert await us.trigger_us_trade_cycle() == {"ok": 1}
async def _raise_http():
raise HTTPException(status_code=409, detail="x")
monkeypatch.setattr(ums, "us_trade_cycle", _raise_http)
with pytest.raises(HTTPException) as ei:
await us.trigger_us_trade_cycle()
assert ei.value.status_code == 409, "HTTPException 이 500 으로 뭉개졌다"
# ═════════════════════════════════════════════════════════════════════════════
# H-4: 공용 매수 가드
# ═════════════════════════════════════════════════════════════════════════════
def _cap():
return settings.MAX_POSITION_SIZE
def test_h4_requested_quantity_is_clamped_to_cap():
from app.services.buy_guards import size_manual_buy_v2
total, cash, px = 10_000_000.0, 5_000_000.0, 10_000.0
d = size_manual_buy_v2(
stock_code="005930", order_price=px, current_price=px, requested_quantity=1000,
total_assets=total, available_cash=cash, position_cap_ratio=_cap(),
existing_qty=0, leverage_room=None,
)
assert d.clamped is True
assert d.quantity == int(total * _cap() // px)
assert d.quantity * px <= total * _cap()
def test_h4_cumulative_cap_subtracts_existing_holding():
from app.services.buy_guards import BuyGuardRejected, size_manual_buy_v2
total, px = 10_000_000.0, 10_000.0
full_qty = int(total * _cap() // px)
# 이미 상한 꽉 참 → 거부
with pytest.raises(BuyGuardRejected) as ei:
size_manual_buy_v2(
stock_code="005930", order_price=px, current_price=px, requested_quantity=None,
total_assets=total, available_cash=total, position_cap_ratio=_cap(),
existing_qty=full_qty, leverage_room=None,
)
assert ei.value.reason == "budget_below_min"
# 절반 보유 → 나머지 절반만
half = full_qty // 2
d = size_manual_buy_v2(
stock_code="005930", order_price=px, current_price=px, requested_quantity=None,
total_assets=total, available_cash=total, position_cap_ratio=_cap(),
existing_qty=half, leverage_room=None,
)
assert (half + d.quantity) * px <= total * _cap() + 1e-6
def test_h4_cash_reserve_enforced():
from app.services.buy_guards import BuyGuardRejected, size_manual_buy_v2
total, px = 10_000_000.0, 10_000.0
reserve = total * (1 - settings.MAX_TOTAL_CAPITAL_USAGE)
with pytest.raises(BuyGuardRejected):
size_manual_buy_v2(
stock_code="005930", order_price=px, current_price=px, requested_quantity=1,
total_assets=total, available_cash=reserve + px / 2, position_cap_ratio=_cap(),
existing_qty=0, leverage_room=None,
)
def test_h4_leverage_room_includes_this_order():
from app.services.buy_guards import leverage_room_v2
total = 10_000_000.0
held = [SimpleNamespace(stock_name="KODEX 레버리지", quantity=100,
current_price=total * settings.LEVERAGE_ETF_MAX_RATIO / 100 - 1000,
average_price=0)]
room = leverage_room_v2("TIGER 인버스", held, total)
assert room == pytest.approx(100 * 1000)
assert leverage_room_v2("삼성전자", held, total) is None
def test_h4_cash_field_trust_order():
from app.services.buy_guards import pick_available_cash_v2
assert pick_available_cash_v2({
"dnca_tot_amt": "-913965", "nxdy_excc_amt": "4524276", "prvs_rcdl_excc_amt": "5224855",
}) == 5224855.0
@pytest.mark.parametrize("existing_qty,invest", [(0, 400_000), (10, 400_000), (25, 50_000), (39, 10_000)])
def test_h4_position_room_matches_trade_executor(existing_qty, invest):
"""buy_guards 의 누적 상한 정의가 trade_executor._apply_position_cap_room_v2 와 같다."""
from app.services import trade_executor as te
from app.services.buy_guards import position_cap_room_v2
total, px = 10_000_000.0, 10_000.0
holding = SimpleNamespace(quantity=existing_qty)
te_amount, te_reason = te._apply_position_cap_room_v2(
invest, holding, "TEST01", "t", px, total, _cap(),
)
room = position_cap_room_v2(total, _cap(), existing_qty, px)
if existing_qty == 0:
assert te_reason is None and room == pytest.approx(total * _cap())
elif te_reason is None:
assert te_amount == pytest.approx(min(invest, room))
else:
assert te_reason == "position_cap_reached"
class _Res:
def __init__(self, v):
self.v = v
def scalar_one_or_none(self):
return self.v
def scalar(self):
return self.v
def scalars(self):
return SimpleNamespace(all=lambda: list(self.v or []), first=lambda: (self.v or [None])[0])
class _FakeDB:
def __init__(self, results):
self.results = list(results)
self.stmts = []
async def execute(self, stmt):
self.stmts.append(stmt)
return _Res(self.results.pop(0))
async def delete(self, obj):
raise _MustNotCall("직접 delete")
async def commit(self):
raise _MustNotCall("직접 commit")
def _adp(mode="normal", fallback=False, size=None, trades=5):
return SimpleNamespace(
fallback_used=fallback, trading_mode=mode,
position_size=size if size is not None else settings.MAX_POSITION_SIZE,
max_daily_trades=trades, rsi_entry=settings.MEAN_REVERSION_RSI_THRESHOLD,
bear_rsi_entry=settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD,
)
@pytest.fixture
def manual_buy_env(monkeypatch):
from app.api.endpoints import manual_buy as mb
from app.services import trade_executor as te
import app.services.adaptive_strategy as ad
import app.services.correlation_filter as cf
import app.services.db_worker as dbw
import app.services.system_buy_registry as sbr
total, cash, px = 10_000_000.0, 5_000_000.0, 10_000.0
k = mb.korea_investment_api
monkeypatch.setattr(k, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": str(int(px)), "hts_kor_isnm": "삼성전자"}}))
monkeypatch.setattr(k, "get_balance", AsyncMock(return_value={"output2": [{"tot_evlu_amt": str(total), "dnca_tot_amt": "-1"}]}))
monkeypatch.setattr(k, "get_buyable_cash", AsyncMock(return_value=cash))
monkeypatch.setattr(mb.loss_tracker, "is_banned_safe", AsyncMock(return_value=False))
monkeypatch.setattr(mb.mdd_manager, "can_open_new_position", AsyncMock(return_value=True))
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: _adp())
monkeypatch.setattr(cf, "validate_correlation_filter", AsyncMock(return_value=(True, "ok")))
orders = []
async def _order(stock_code, order_type, quantity, price=None, **kw):
orders.append(quantity)
return {"rt_cd": "0", "output": {"ODNO": "1"}}
monkeypatch.setattr(te, "safe_place_order", _order)
async def _noop(*a, **k):
return None
monkeypatch.setattr(te, "_delayed_sync_portfolio", _noop)
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _noop)
monkeypatch.setattr(sbr, "note_system_buy", lambda *a, **k: None)
return SimpleNamespace(mb=mb, total=total, cash=cash, px=px, orders=orders, ad=ad, k=k, cf=cf)
@pytest.mark.asyncio
async def test_h4_manual_buy_endpoint_clamps_requested_quantity(manual_buy_env):
e = manual_buy_env
db = _FakeDB([None, 0, []]) # holding / count / current_holdings
req = e.mb.BuyRequest(stock_code="005930", quantity=1000)
out = await e.mb._buy_stock_impl(req, "005930", db)
expected = int(e.total * settings.MAX_POSITION_SIZE // e.px)
assert e.orders == [expected]
assert out["clamped"] is True and out["requested_quantity"] == 1000
@pytest.mark.asyncio
async def test_h4_manual_buy_endpoint_cumulative_with_existing(manual_buy_env):
e = manual_buy_env
full = int(e.total * settings.MAX_POSITION_SIZE // e.px)
holding = SimpleNamespace(quantity=full, stock_name="삼성전자")
db = _FakeDB([holding, [holding]])
with pytest.raises(HTTPException) as ei:
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", db)
assert ei.value.status_code == 400
assert e.orders == []
@pytest.mark.asyncio
async def test_h4_manual_buy_blocked_in_crisis(manual_buy_env, monkeypatch):
e = manual_buy_env
monkeypatch.setattr(e.ad, "get_current_adaptive_params", lambda: _adp(mode="crisis", trades=0))
with pytest.raises(HTTPException) as ei:
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
assert ei.value.status_code == 423
assert e.orders == []
@pytest.mark.asyncio
async def test_h4_manual_buy_rejects_on_balance_failure(manual_buy_env, monkeypatch):
e = manual_buy_env
async def _fail(*a, **k):
raise RuntimeError("KIS down")
monkeypatch.setattr(e.k, "get_balance", _fail)
with pytest.raises(HTTPException) as ei:
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", quantity=1), "005930", _FakeDB([None, 0, []]))
assert ei.value.status_code == 502
assert e.orders == []
@pytest.mark.asyncio
async def test_h4_manual_buy_rejects_on_sector_filter(manual_buy_env, monkeypatch):
e = manual_buy_env
monkeypatch.setattr(e.cf, "validate_correlation_filter", AsyncMock(return_value=(False, "sector_full")))
with pytest.raises(HTTPException) as ei:
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
assert ei.value.status_code == 400
assert e.orders == []
# ═════════════════════════════════════════════════════════════════════════════
# M-5: 수동 청산 부기
# ═════════════════════════════════════════════════════════════════════════════
def test_m5_allocation_dt_first_and_swing_only():
from app.services.manual_sell_service import allocate_sell_v2
a = allocate_sell_v2(sold=6, row_qty_before=10, dt_qty=4, swing_only=False)
assert (a.dt_sold, a.swing_sold, a.dt_left, a.swing_left) == (4, 2, 0, 4)
b = allocate_sell_v2(sold=6, row_qty_before=10, dt_qty=4, swing_only=True)
assert (b.dt_sold, b.swing_sold, b.swing_left) == (0, 6, 0)
c = allocate_sell_v2(sold=3, row_qty_before=5, dt_qty=9, swing_only=False) # stale DT
assert c.dt_before == 5 and c.dt_sold == 3
@pytest.mark.asyncio
async def test_m5_active_trade_payloads_always_carry_strategy_filter(monkeypatch):
import app.services.db_worker as dbw
from app.services.manual_sell_service import allocate_sell_v2, sync_active_trade_after_manual_sell_v2
writes, upserts = [], []
async def _w(task):
writes.append(task)
async def _u(p):
upserts.append(p)
monkeypatch.setattr(dbw, "enqueue_db_write", _w)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _u)
# 전량(스윙 6 + DT 4) → 비-DT close + DT close
await sync_active_trade_after_manual_sell_v2("005930", allocate_sell_v2(10, 10, 4, False))
assert [w["data"].get("strategy_name_not") or w["data"].get("strategy_names") for w in writes]
assert all(("strategy_name_not" in w["data"]) ^ ("strategy_names" in w["data"]) for w in writes)
assert len(writes) == 2 and upserts == []
# 부분(DT 4 전량 + 스윙 2) → DT close, 스윙 upsert(잔량 4, 비율 4/6)
writes.clear()
await sync_active_trade_after_manual_sell_v2("005930", allocate_sell_v2(6, 10, 4, False))
assert len(writes) == 1 and "strategy_names" in writes[0]["data"]
assert upserts[0]["quantity"] == 4 and upserts[0]["strategy_name_not"]
assert upserts[0]["remaining_pct_multiply"] == pytest.approx(4 / 6)
def test_m5_swing_sell_request_rejects_nonpositive():
from app.api.endpoints.swing_portfolio import SwingSellRequest
for bad in (0, -3):
with pytest.raises(ValidationError):
SwingSellRequest(stock_code="005930", quantity=bad)
@pytest.fixture
def swing_sell_env(monkeypatch):
from app.api.endpoints import swing_portfolio as sp
from app.services import trade_executor as te
import app.services.db_worker as dbw
import app.services.portfolio_cache_bridge as pcb
from app.services.loss_tracker import loss_tracker
from app.services.daytrading.position_manager import day_trading_state
monkeypatch.setattr(sp.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "10000"}}))
orders, writes, reflected = [], [], []
async def _order(stock_code, order_type, quantity, price=None, **kw):
orders.append((order_type, quantity))
return {"rt_cd": "0", "output": {"ODNO": "9"}}
monkeypatch.setattr(te, "safe_place_order", _order)
async def _noop(*a, **k):
return None
async def _w(task):
writes.append(task)
async def _reflect(code, qty, price, remaining_qty=None):
reflected.append((code, qty, remaining_qty))
monkeypatch.setattr(dbw, "enqueue_trade_history", _noop)
monkeypatch.setattr(dbw, "enqueue_db_write", _w)
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", _noop)
monkeypatch.setattr(pcb, "reflect_sell", _reflect)
rec = AsyncMock()
save = AsyncMock()
monkeypatch.setattr(loss_tracker, "record_trade_result_safe", rec)
monkeypatch.setattr(loss_tracker, "save_to_db", save)
day_trading_state.positions.pop("005930", None)
day_trading_state.positions["005930"] = {"quantity": 4, "entry_price": 10000}
yield SimpleNamespace(sp=sp, orders=orders, writes=writes, reflected=reflected, rec=rec, save=save)
day_trading_state.positions.pop("005930", None)
@pytest.mark.asyncio
async def test_m5_swing_sell_excludes_dt_shares(swing_sell_env):
e = swing_sell_env
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=11000, strategy_type="swing")
with pytest.raises(HTTPException) as ei:
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930", quantity=8), db=_FakeDB([holding]))
assert ei.value.status_code == 400 and e.orders == []
out = await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
assert e.orders == [("SELL", 6)], "DT 보유분(4주)까지 매도했다"
assert out["quantity"] == 6
# ActiveTrade 는 비-DT 필터로 close, Portfolio 는 큐 경유 반영
assert e.writes and all("strategy_name_not" in w["data"] for w in e.writes)
assert e.reflected == [("005930", 6, 4)]
# 스윙분 전량 청산 → loss_tracker record + save_to_db
e.rec.assert_awaited_once()
e.save.assert_awaited_once()
def test_m5_legacy_sell_no_longer_closes_without_strategy_name():
import inspect
from app.api.endpoints import portfolio as pf
src = inspect.getsource(pf.sell_stock)
assert "strategy_name=None" not in src
assert "sync_active_trade_after_manual_sell_v2" in src
assert "await db.commit()" not in src, "Portfolio 직접 쓰기(§3.2)"
# ═════════════════════════════════════════════════════════════════════════════
# C1: 추천 스캐너 평균회귀 임계 출처 플래그
# ═════════════════════════════════════════════════════════════════════════════
def _stock(rsi):
return SimpleNamespace(
rsi=rsi, current_price=9_100, bollinger_lower=9_000, bollinger_upper=11_000,
bollinger_middle=10_000, ma20=None, ma60=None, adx_14=20,
)
@pytest.fixture
def mr_env(monkeypatch):
import app.services.adaptive_strategy as ad
from app.services.strategy_config_service import strategy_cfg
state = {"adp": _adp(fallback=True), "cfg": {}}
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: state["adp"])
monkeypatch.setattr(strategy_cfg, "get", lambda key, default=None: state["cfg"].get(key, default))
return state
def test_c1_flag_off_keeps_trading_rules_thresholds(monkeypatch, mr_env):
from app.api.endpoints import recommendations as rec
monkeypatch.setattr(settings, "MEAN_REVERSION_CANONICAL_PARAMS_ENABLED", False)
mr_env["adp"] = _adp(fallback=False) # OFF 면 적응형도 무시해야 한다
assert rec._mean_reversion_rsi_thresholds_v2({})[:2] == (40, 30)
assert rec._mean_reversion_rsi_thresholds_v2({"rsi_bull_max": 33, "rsi_bear_max": 22})[:2] == (33, 22)
assert rec.is_mean_reversion_candidate(_stock(40), {}, "bull") is True
assert rec.is_mean_reversion_candidate(_stock(41), {}, "bull") is False
assert rec.is_mean_reversion_candidate(_stock(31), {}, "bear") is False
def test_c1_flag_on_priority_adaptive_then_cfg_then_settings(monkeypatch, mr_env):
from app.api.endpoints import recommendations as rec
monkeypatch.setattr(settings, "MEAN_REVERSION_CANONICAL_PARAMS_ENABLED", True)
# settings
b, r, src = rec._mean_reversion_rsi_thresholds_v2({"rsi_bull_max": 1})
assert (b, r, src) == (settings.MEAN_REVERSION_RSI_THRESHOLD, settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD, "settings")
# strategy_cfg > settings
mr_env["cfg"] = {"MEAN_REVERSION_RSI_THRESHOLD": "44", "MEAN_REVERSION_BEAR_RSI_THRESHOLD": "33"}
assert rec._mean_reversion_rsi_thresholds_v2({})[:2] == (44.0, 33.0)
# adaptive > strategy_cfg
mr_env["adp"] = SimpleNamespace(**{**vars(_adp(fallback=False)), "rsi_entry": 38.0, "bear_rsi_entry": 28.0})
assert rec._mean_reversion_rsi_thresholds_v2({})[:2] == (38.0, 28.0)
assert rec.is_mean_reversion_candidate(_stock(38), {}, "bull") is True
assert rec.is_mean_reversion_candidate(_stock(39), {}, "bull") is False
# ═════════════════════════════════════════════════════════════════════════════
# Low: DELETE /trades/pending 은 당일 행을 지우지 않는다
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_low_delete_pending_only_before_today():
from app.api.endpoints import trades as tr
class _DB(_FakeDB):
async def commit(self):
return None
db = _DB([[]])
out = await tr.delete_pending_trades(db=db)
assert out["deleted_count"] == 0
sql = str(db.stmts[0].compile(compile_kwargs={"literal_binds": False}))
assert "created_at <" in sql, "당일 이전 조건이 없다 — 당일 PENDING(일일 한도 근거)까지 삭제"