Files
auto-stock/backend/tests/test_swing_pm_hardening_r1_20261009.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

222 lines
8.3 KiB
Python

"""[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")
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)