- 스윙 체결가 확인 전 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
650 lines
30 KiB
Python
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"}
|