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:
524
backend/tests/test_guard_bypass_endpoints_20261002.py
Normal file
524
backend/tests/test_guard_bypass_endpoints_20261002.py
Normal 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(일일 한도 근거)까지 삭제"
|
||||
Reference in New Issue
Block a user