- 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>
222 lines
8.3 KiB
Python
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 실접속 방지)
|