"""[2026-10-09 하드닝 R1] swing/position_manager 회귀 테스트. 1. 진입가 괴리 > 한도 → 유령 고가 entry 로 허위 전량 손절하지 않는다(KIS 평단 사용). 2. Portfolio 태그 daytrading + 비-DT active ActiveTrade → 관리 주체 없는 수량 경보(105560). 3. 스윙 SELL 출처(system/inflow) 분리 — 동기화 유입분이 전략 성과에 섞이지 않게. """ import asyncio import logging from datetime import datetime, timedelta from types import SimpleNamespace import pytest from app.core.config import settings from app.services.swing import position_manager as pm def _run(coro): return asyncio.run(coro) # ── 1. 진입가 해석 ────────────────────────────────────────────────────────── def test_ghost_high_entry_does_not_trigger_false_stop(): """유령 entry 100,000 / 실평단 50,000 / 현재가 52,000 → 이전엔 -48% 전량 손절.""" price, basis = pm.resolve_swing_entry_price_v2(100_000, 50_000, "TEST01") assert (price, basis) == (50_000.0, "portfolio_avg_deviation") pnl_pct = (52_000 / price - 1.0) * 100 hard_floor = -abs(float(settings.STOP_LOSS_PERCENT)) assert pnl_pct > hard_floor, "실제로는 +4% — 손절 판정되면 안 된다" def test_ghost_low_entry_does_not_delay_stop(): """반대 방향: 유령 entry 가 낮아도 KIS 평단 기준이므로 손절이 늦춰지지 않는다.""" pa = 50_000.0 price, basis = pm.resolve_swing_entry_price_v2(pa * 0.5, pa, "TEST02") assert (price, basis) == (pa, "portfolio_avg_deviation") hard_floor = -abs(float(settings.STOP_LOSS_PERCENT)) cur = pa * (1 + (hard_floor - 1.0) / 100) # 하드 바닥보다 1%p 더 하락 assert (cur / price - 1.0) * 100 <= hard_floor def test_within_band_still_uses_active_trade(): pa = 10_000.0 at = pa * (1 + pm.SWING_ENTRY_PRICE_MAX_DEVIATION * 0.5) assert pm.resolve_swing_entry_price_v2(at, pa) == (pytest.approx(at), "active_trade") # ── 2. 태그 불일치 탐지 ──────────────────────────────────────────────────── def _p(code, qty, st): return SimpleNamespace(stock_code=code, quantity=qty, strategy_type=st) def _a(code, name, id_=1): return SimpleNamespace(stock_code=code, strategy_name=name, id=id_, status="active") def test_105560_dt_tag_with_synced_active_is_flagged(): found = pm.find_strategy_tag_mismatches_v2( [_p("105560", 3, "daytrading")], [_a("105560", "synced", 178)], {}, ) assert len(found) == 1 m = found[0] assert m["stock_code"] == "105560" and m["unmanaged_qty"] == 3 assert m["active_trade_ids"] == [178] def test_dt_inmemory_covering_qty_is_not_flagged(): assert pm.find_strategy_tag_mismatches_v2( [_p("105560", 3, "daytrading")], [_a("105560", "synced")], {"105560": 3}, ) == [] # 부분 커버 → 잔여만 경보 found = pm.find_strategy_tag_mismatches_v2( [_p("105560", 6, "daytrading")], [_a("105560", "synced")], {"105560": 3}, ) assert found[0]["unmanaged_qty"] == 3 @pytest.mark.parametrize("portfolio,active", [ ([_p("105560", 3, "daytrading")], [_a("105560", "daytrading")]), # DT 행뿐 ([_p("105560", 3, "daytrading")], [_a("105560", "day_trade")]), ([_p("105560", 3, "swing")], [_a("105560", "synced")]), # 스윙 태그는 스윙 청산이 봄 ([_p("105560", 0, "daytrading")], [_a("105560", "synced")]), # 수량 0 ([_p("105560", 3, "daytrading")], []), ]) def test_non_mismatch_cases(portfolio, active): assert pm.find_strategy_tag_mismatches_v2(portfolio, active, {}) == [] def test_null_strategy_name_counts_as_non_dt(): found = pm.find_strategy_tag_mismatches_v2( [_p("105560", 3, "day_trade")], [_a("105560", None)], {}, ) assert found and found[0]["unmanaged_qty"] == 3 class _Res: def __init__(self, rows): self._rows = rows def scalars(self): return self def all(self): return list(self._rows) class _FakeDB: """execute 응답을 순서대로 반환. 예외 항목은 raise. 쓰기 메서드는 예외.""" def __init__(self, responses): self._responses = list(responses) self.calls = 0 async def execute(self, _stmt): self.calls += 1 r = self._responses.pop(0) if isinstance(r, BaseException): raise r return _Res(r) def add(self, *_a, **_k): # pragma: no cover raise AssertionError("position_manager 는 쓰기 금지") async def commit(self): # pragma: no cover raise AssertionError("position_manager 는 commit 금지") @pytest.fixture def _fresh_throttle(monkeypatch): from app.core import log_throttle monkeypatch.setattr(log_throttle, "_state", {}) @pytest.fixture def _no_dt_positions(monkeypatch): from app.services.daytrading.position_manager import day_trading_state monkeypatch.setattr(day_trading_state, "positions", {}) def test_detect_logs_error_for_unmanaged(caplog, _fresh_throttle, _no_dt_positions): db = _FakeDB([[_p("105560", 3, "daytrading")], [_a("105560", "synced", 178)]]) with caplog.at_level(logging.ERROR, logger=pm.logger.name): found = _run(pm.detect_strategy_tag_mismatch_v2(db)) assert [m["stock_code"] for m in found] == ["105560"] assert any("105560" in r.message and "손절 없음" in r.message for r in caplog.records) def test_detect_skips_active_query_when_no_dt_tagged_rows(_no_dt_positions): db = _FakeDB([[]]) assert _run(pm.detect_strategy_tag_mismatch_v2(db)) == [] assert db.calls == 1 def test_get_swing_holdings_survives_detection_failure(caplog, _no_dt_positions): """경보 실패가 청산 대상 조회를 막으면 안 된다(청산 우선).""" holding = _p("005930", 5, "swing") db = _FakeDB([[holding], RuntimeError("db down")]) with caplog.at_level(logging.WARNING, logger=pm.logger.name): out = _run(pm.get_swing_holdings(db)) assert list(out) == [holding] assert any("탐지 실패" in r.message and r.levelno >= logging.WARNING for r in caplog.records) def test_dt_snapshot_failure_falls_back_to_alerting(monkeypatch): from app.services.daytrading.position_manager import day_trading_state class _Boom: def keys(self): raise RuntimeError("boom") def items(self): raise RuntimeError("boom") monkeypatch.setattr(day_trading_state, "positions", _Boom()) assert pm._dt_inmemory_qty_snapshot_v2() == {} # ── 3. 스윙 SELL 출처 분리 ────────────────────────────────────────────────── def _t(code, side, qty, minutes, st="swing", status="COMPLETED", id_=None): return { "id": id_, "stock_code": code, "order_type": side, "executed_quantity": qty, "created_at": datetime(2026, 10, 1, 9, 0) + timedelta(minutes=minutes), "strategy_type": st, "status": status, } def test_sell_without_prior_swing_buy_is_inflow(): out = pm.split_swing_sells_by_origin_v2([ _t("022100", "SELL", 10, 0, id_=1), _t("022100", "BUY", 5, 10), _t("022100", "SELL", 5, 20, id_=3), ]) assert [(o["id"], o["origin"], o["system_qty"], o["inflow_qty"]) for o in out] == [ (1, "inflow", 0, 10), (3, "system", 5, 0), ] def test_sell_exceeding_system_holding_is_mixed(): out = pm.split_swing_sells_by_origin_v2([ _t("009420", "BUY", 3, 0), _t("009420", "SELL", 8, 5, id_=2), ]) assert out[0]["origin"] == "mixed" assert (out[0]["system_qty"], out[0]["inflow_qty"]) == (3, 5) def test_unsorted_input_and_ignored_rows(): out = pm.split_swing_sells_by_origin_v2([ _t("323410", "SELL", 4, 30, id_=9), _t("323410", "BUY", 4, 0, st="daytrading"), # DT 매수는 스윙 보유 아님 _t("323410", "BUY", 4, 10, status="FAILED"), # 실패 매수 무시 _t("323410", "BUY", 2, 20), ]) assert len(out) == 1 assert (out[0]["system_qty"], out[0]["inflow_qty"], out[0]["origin"]) == (2, 2, "mixed")