Files
auto-stock/backend/tests/test_swing_pm_hardening_r1_20261009.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

219 lines
8.2 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")