Files
auto-stock/backend/tests/test_guard_bypass_endpoints_20261002.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

650 lines
30 KiB
Python

"""[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)
verifies = []
async def _verify(**kw):
verifies.append(kw)
monkeypatch.setattr(te, "_verify_and_sync_execution_v3", _verify)
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, verifies=verifies, 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(일일 한도 근거)까지 삭제"
@pytest.mark.asyncio
async def test_r2_swing_manual_sell_loss_is_net_of_costs(swing_sell_env, monkeypatch):
"""왕복 비용 이내 소폭 이익 수동 청산은 패(순손익 기준, §5.1), TradeHistory 는 순손익 저장."""
import app.services.db_worker as dbw
e = swing_sell_env
hist = []
async def _h(d):
hist.append(d)
monkeypatch.setattr(dbw, "enqueue_trade_history", _h)
# 현재가 10000, 진입가 settings 비용(약 0.21%)보다 작은 0.05% 이익
entry = 10000 / 1.0005
at = SimpleNamespace(entry_price=entry)
monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade",
AsyncMock(return_value=at))
monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2",
AsyncMock(return_value=None))
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing")
from app.services.swing.exit_service import _swing_net_pnl_v2
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
expected = _swing_net_pnl_v2(entry, 10000, 6, "KOSPI")["net_profit_loss"]
assert expected < 0 < (10000 - entry) * 6, "전제: gross 이익 / net 손실"
assert hist[0]["net_profit_loss"] == pytest.approx(expected)
assert e.rec.await_args.kwargs["is_loss"] is True
@pytest.mark.asyncio
async def test_r2_swing_manual_sell_prior_leg_profit_makes_win(swing_sell_env, monkeypatch):
"""앞 레그 실현이익이 마지막 소폭 손실 레그를 상쇄하면 승."""
e = swing_sell_env
entry = 10100.0 # 현재가 10000 → gross 손실
monkeypatch.setattr("app.services.swing.position_manager.get_swing_active_trade",
AsyncMock(return_value=SimpleNamespace(entry_price=entry)))
monkeypatch.setattr("app.services.swing.exit_service._prior_position_realized_net_v2",
AsyncMock(return_value=100000.0))
holding = SimpleNamespace(quantity=10, stock_name="삼성전자", average_price=entry, strategy_type="swing")
await e.sp.manual_swing_sell(e.sp.SwingSellRequest(stock_code="005930"), db=_FakeDB([holding]))
assert e.rec.await_args.kwargs["is_loss"] is False
# ── R3 2026-10-09: 수동 매수 결과 불명 / 지정가 PENDING ──────────────────────
@pytest.fixture
def mb_capture(manual_buy_env, monkeypatch):
import app.services.db_worker as dbw
import app.services.trade_executor as te
hist, ports = [], []
async def _h(d):
hist.append(d)
async def _p(d):
ports.append(d)
monkeypatch.setattr(dbw, "enqueue_trade_history", _h)
monkeypatch.setattr(dbw, "enqueue_portfolio_upsert", _p)
e = manual_buy_env
e.mb._last_done.clear()
e.hist, e.ports, e.te = hist, ports, te
monkeypatch.setattr(e.mb, "spawn_tracked_v2", lambda c: c.close())
return e
@pytest.mark.asyncio
async def test_r3_manual_buy_transport_unknown_sets_cooldown(mb_capture, monkeypatch):
e = mb_capture
async def _unk(**kw):
return {"rt_cd": "-1", "msg_cd": "KIS_TRANSPORT_UNKNOWN", "msg1": "timeout"}
monkeypatch.setattr(e.te, "safe_place_order", _unk)
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 == 504
assert "005930" in e.mb._last_done
assert e.hist and e.hist[0]["status"] == "PENDING" and not e.ports
@pytest.mark.asyncio
async def test_r3_manual_buy_presend_failure_no_cooldown(mb_capture, monkeypatch):
e = mb_capture
async def _pre(**kw):
return {"rt_cd": "-1", "msg_cd": "KIS_PRESEND_CONNECT", "msg1": "x"}
monkeypatch.setattr(e.te, "safe_place_order", _pre)
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 == 500
assert "005930" not in e.mb._last_done and not e.hist
@pytest.mark.asyncio
async def test_r3_manual_buy_limit_order_pending_no_portfolio_row(mb_capture):
e = mb_capture
px = int(e.px * 0.9)
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", price=px), "005930", _FakeDB([None, 0, []]))
assert e.hist[0]["status"] == "PENDING" and e.hist[0]["executed_quantity"] == 0
assert not e.ports
@pytest.mark.asyncio
async def test_r3_manual_buy_market_order_unchanged(mb_capture):
e = mb_capture
await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930"), "005930", _FakeDB([None, 0, []]))
assert e.hist[0]["status"] == "COMPLETED" and e.hist[0]["executed_quantity"] == e.orders[0]
assert len(e.ports) == 1
@pytest.mark.asyncio
async def test_r7_manual_limit_buy_spawns_fill_verification(manual_buy_env):
"""지정가 수동 매수의 PENDING 행을 확정할 체결 확인 태스크가 ODNO 로 연결돼야 한다."""
import asyncio
e = manual_buy_env
req = e.mb.BuyRequest(stock_code="005930", price=int(e.px))
await e.mb._buy_stock_impl(req, "005930", _FakeDB([None, 0, []]))
await asyncio.sleep(0)
await asyncio.sleep(0)
assert len(e.verifies) == 1
v = e.verifies[0]
assert v["order_id"] == "1" and v["order_type"] == "BUY" and v["is_domestic"] is True
assert v["sell_pnl_basis"] == {"strategy_type": "manual"}