- 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>
353 lines
18 KiB
Python
353 lines
18 KiB
Python
"""스윙 청산 하드닝 R1 (2026-10-09) — swing/exit_service 회귀 테스트.
|
||
|
||
#1/#3 실시세 정상 + 일봉/지표 조회 실패 시 폴백 손절이 ATR=None → 일반 손절(-7%)로 고변동 종목을
|
||
오손절하던 것 → 대체가 경로와 같은 규칙(캐시 ATR → 느슨한 쪽).
|
||
#2 'synced'/'외부 유입' 시딩 행의 시간손절 진입일 보정 + TradeHistory 기반 전량청산 하한.
|
||
#4 전량 청산 loss_tracker 승패 = 포지션 누적 순손익(이전 분할 레그 + 이번 레그).
|
||
#6 스윙 SELL TradeHistory 의 commission·net_profit_loss(비용 차감).
|
||
|
||
규약(CLAUDE.md §4.1): 주문 목은 공통 종단(safe_place_order), 기대값은 settings.* 참조.
|
||
"""
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
import app.services.mean_reversion_strategy as mr
|
||
from app.core.config import settings
|
||
from app.services.mean_reversion_strategy import ExitSignal
|
||
|
||
OK = {"rt_cd": "0", "output": {"ODNO": "9"}}
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset(monkeypatch):
|
||
import app.services.swing.exit_service as es
|
||
for name in ("_pending_unknown_sells", "_last_live_prices", "_last_atr",
|
||
"_presell_check_failures", "_no_price_skips", "_recent_exits"):
|
||
monkeypatch.setattr(es, name, {})
|
||
from app.core import log_throttle
|
||
log_throttle.reset_for_tests()
|
||
import app.services.adaptive_strategy as ad
|
||
monkeypatch.setattr(ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True))
|
||
monkeypatch.setattr(mr.strategy_cfg, "get", lambda k, d=None: None)
|
||
import app.services.trade_executor as te
|
||
monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock())
|
||
|
||
|
||
class _RowsDB:
|
||
"""execute 마다 미리 준비한 행 목록을 .all()/.scalar() 로 돌려주고 문장을 기록한다."""
|
||
|
||
def __init__(self, *row_sets):
|
||
self.row_sets = list(row_sets)
|
||
self.statements = []
|
||
|
||
async def execute(self, stmt):
|
||
self.statements.append(stmt)
|
||
rows = self.row_sets.pop(0) if self.row_sets else []
|
||
return SimpleNamespace(all=lambda: list(rows), scalar=lambda: (rows[0] if rows else None))
|
||
|
||
|
||
class _BoomDB:
|
||
async def execute(self, stmt):
|
||
raise RuntimeError("db down")
|
||
|
||
|
||
def _sql(stmt) -> str:
|
||
return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}))
|
||
|
||
|
||
def _env(monkeypatch, *, avg=10000, price, high_vol=True, chart=None, signal=None,
|
||
order=None, at_kwargs=None):
|
||
"""chart: None=정상 30봉, Exception=조회 실패, list=그 봉들."""
|
||
import app.services.swing.exit_service as es
|
||
import app.services.trade_executor as te
|
||
import app.services.db_worker as dbw
|
||
import app.services.portfolio_cache_bridge as pcb
|
||
from app.core.timeutil import now_kst
|
||
from app.services.daytrading.position_manager import day_trading_state
|
||
|
||
holding = SimpleNamespace(stock_code="005930", stock_name="T", quantity=10, average_price=avg,
|
||
current_price=price, market="KRX", updated_at=now_kst())
|
||
monkeypatch.setattr(es, "get_swing_holdings", AsyncMock(return_value=[holding]))
|
||
monkeypatch.setattr(es, "check_market_regime", AsyncMock(return_value={"regime_200d": "bull"}))
|
||
at = SimpleNamespace(strategy_name="swing", remaining_pct=1.0, highest_since_entry=None,
|
||
created_at=now_kst() - timedelta(days=2), entry_reason=None,
|
||
entry_price=avg)
|
||
for k, v in (at_kwargs or {}).items():
|
||
setattr(at, k, v)
|
||
monkeypatch.setattr(es, "get_swing_active_trade", AsyncMock(return_value=at))
|
||
api = MagicMock()
|
||
rng = 250 if high_vol else 40 # 고변동: ATR≈500(>3%)
|
||
bars = [{"stck_clpr": str(price + 100 + (i % 3) * 50), "stck_hgpr": str(price + 100 + rng + (i % 3) * 50),
|
||
"stck_lwpr": str(price + 100 - rng + (i % 3) * 50)} for i in range(30)]
|
||
if isinstance(chart, Exception):
|
||
api.get_daily_chart = AsyncMock(side_effect=chart)
|
||
else:
|
||
api.get_daily_chart = AsyncMock(return_value={"output2": bars if chart is None else chart})
|
||
api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}})
|
||
api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [
|
||
{"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]})
|
||
api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유
|
||
monkeypatch.setattr(es, "korea_investment_api", api)
|
||
if signal is not None:
|
||
monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal)
|
||
monkeypatch.setattr(day_trading_state, "positions", {})
|
||
monkeypatch.setattr(day_trading_state, "save_daily_state", AsyncMock())
|
||
sent = []
|
||
|
||
async def _spo(**kw):
|
||
sent.append(kw["quantity"])
|
||
return order if order is not None else {"rt_cd": "1", "msg_cd": "X", "msg1": "stop here"}
|
||
monkeypatch.setattr(te, "safe_place_order", _spo)
|
||
history = []
|
||
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock(side_effect=lambda p: history.append(p)))
|
||
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
|
||
monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", AsyncMock())
|
||
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
|
||
monkeypatch.setattr(es, "_schedule_swing_sell_fill_check_v2", MagicMock())
|
||
# [R1-b] 보유 집계 SELECT(휴면·페이지 감시)는 _RowsDB 의 순차 결과를 소비하므로 제외 —
|
||
# 집계 자체는 test_swing_exit_hardening_r1b_20261009 에서 검증한다.
|
||
monkeypatch.setattr(es, "_portfolio_census_v2", AsyncMock(return_value=None))
|
||
loss = AsyncMock()
|
||
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", loss)
|
||
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
|
||
return SimpleNamespace(es=es, sent=sent, history=history, loss=loss, at=at)
|
||
|
||
|
||
async def _cycle(es, db=None):
|
||
es._recent_exits.clear()
|
||
await es.check_and_execute_exit_v2(db if db is not None else MagicMock())
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# #1 / #3 — 실시세 정상 + 일봉 조회 실패 폴백 손절
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_nonstale_chart_failure_uses_cached_high_vol_atr(monkeypatch):
|
||
"""[재현 반전] 고변동 -8%: 정상 주기 보유 → 차트 실패 주기에도 보유(이전: -7% 시장가 오손절)."""
|
||
env = _env(monkeypatch, price=9200)
|
||
await _cycle(env.es)
|
||
assert env.sent == [] and "005930" in env.es._last_atr
|
||
env2 = _env(monkeypatch, price=9200, chart=RuntimeError("EGW00201"))
|
||
await _cycle(env2.es)
|
||
assert env2.sent == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_nonstale_short_chart_without_cache_uses_loosest(monkeypatch):
|
||
loosest = -max(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL))
|
||
tight = -min(abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL))
|
||
between = int(10000 * (1 + (tight + loosest) / 2 / 100))
|
||
env = _env(monkeypatch, price=between, chart=[]) # 20봉 미만
|
||
await _cycle(env.es)
|
||
assert env.sent == []
|
||
beyond = int(10000 * (1 + (loosest - 1) / 100))
|
||
env2 = _env(monkeypatch, price=beyond, chart=RuntimeError("down"))
|
||
await _cycle(env2.es)
|
||
assert env2.sent == [10] # 손절 자체는 계속 동작(try 밖 판정)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_nonstale_chart_failure_cached_low_vol_keeps_normal_stop(monkeypatch):
|
||
floor = -abs(settings.STOP_LOSS_PERCENT)
|
||
below = int(10000 * (1 + (floor - 1) / 100))
|
||
env = _env(monkeypatch, price=below, chart=RuntimeError("down"))
|
||
from app.core.timeutil import now_kst
|
||
env.es._last_atr["005930"] = (below * 0.01, time.monotonic(), now_kst()) # ATR 1% 저변동
|
||
await _cycle(env.es)
|
||
assert env.sent == [10]
|
||
|
||
|
||
def test_nonstale_helper_prefers_current_atr_and_signs_negative(monkeypatch):
|
||
import app.services.swing.exit_service as es
|
||
# 이번 ATR 이 있으면 기존 _fallback_stop_loss_pct_v2 경로(저변동 → 일반 손절)
|
||
assert es._nonstale_fallback_stop_pct_v2("005930", 10.0, 10000.0, None) == -abs(settings.STOP_LOSS_PERCENT)
|
||
# 없으면 대체가 규칙(캐시 없음 → 느슨한 쪽)
|
||
assert es._nonstale_fallback_stop_pct_v2("005930", None, 10000.0, None) == -max(
|
||
abs(settings.STOP_LOSS_PERCENT), abs(settings.STOP_LOSS_HIGH_VOL))
|
||
assert es._nonstale_fallback_stop_pct_v2("005930", "bad", 10000.0, None) < 0
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# #2 — 시딩 행 시간손절 진입일
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
_EXT = "외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_v3_synced_row_uses_original_buy_despite_race_closed_row():
|
||
"""181710 재현: BUY 10 (9/23) → 분할 SELL 5 (10/6 09:12) → 레이스로 swing 행 closed →
|
||
synced 재시딩(10/6 09:18). 이전: created_at(10/6). 이제: 9/23."""
|
||
import app.services.swing.exit_service as es
|
||
created = datetime(2026, 10, 6, 9, 18)
|
||
buy = datetime(2026, 9, 23, 12, 53)
|
||
db = _RowsDB([("BUY", 10, 10, buy), ("SELL", 5, 5, datetime(2026, 10, 6, 9, 12))])
|
||
at = SimpleNamespace(created_at=created, entry_reason=_EXT, strategy_name="synced")
|
||
basis = await es._resolve_time_stop_entry_v3(db, "181710", at)
|
||
assert basis.created_at == buy
|
||
sql = _sql(db.statements[0])
|
||
assert "trade_history.status = 'COMPLETED'" in sql
|
||
assert "trade_history.created_at <= '2026-10-06 09:18:00'" in sql
|
||
assert "day_trade" in sql # 비-DT 정본 절
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_v3_recognizes_strategy_name_or_either_prefix():
|
||
import app.services.swing.exit_service as es
|
||
buy = datetime(2026, 9, 1, 10, 0)
|
||
for kw in ({"strategy_name": "synced", "entry_reason": None},
|
||
{"strategy_name": None, "entry_reason": _EXT},
|
||
{"strategy_name": "swing", "entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)"}):
|
||
at = SimpleNamespace(created_at=datetime(2026, 10, 6), **kw)
|
||
basis = await es._resolve_time_stop_entry_v3(_RowsDB([("BUY", 3, 3, buy)]), "X", at)
|
||
assert basis.created_at == buy
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_v3_lower_bound_is_last_flat_sell():
|
||
"""이전 포지션이 TradeHistory 상 전량 청산된 뒤의 BUY 만 진입으로 본다."""
|
||
import app.services.swing.exit_service as es
|
||
rows = [("BUY", 5, 5, datetime(2026, 7, 1)), ("SELL", 5, 5, datetime(2026, 7, 20)),
|
||
("BUY", 4, 4, datetime(2026, 9, 2))]
|
||
at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced")
|
||
basis = await es._resolve_time_stop_entry_v3(_RowsDB(rows), "X", at)
|
||
assert basis.created_at == datetime(2026, 9, 2)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_v3_fully_sold_history_keeps_created_at():
|
||
import app.services.swing.exit_service as es
|
||
rows = [("BUY", 5, 5, datetime(2026, 7, 1)), ("SELL", 5, 5, datetime(2026, 7, 20))]
|
||
at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced")
|
||
assert await es._resolve_time_stop_entry_v3(_RowsDB(rows), "X", at) is at
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_v3_non_seeded_row_does_not_query_and_failure_keeps_created_at():
|
||
import app.services.swing.exit_service as es
|
||
at = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason="기술: RSI", strategy_name="swing")
|
||
assert await es._resolve_time_stop_entry_v3(_BoomDB(), "X", at) is at # DB 미조회
|
||
seeded = SimpleNamespace(created_at=datetime(2026, 10, 6), entry_reason=_EXT, strategy_name="synced")
|
||
assert await es._resolve_time_stop_entry_v3(_BoomDB(), "X", seeded) is seeded
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_v3_wired_into_time_stop(monkeypatch):
|
||
"""synced 행 + 원 BUY 가 SWING_TIME_STOP_DAYS 거래일 이전 → 시간손절 발동(이전: 재시딩일 기준 미발동)."""
|
||
from app.core.timeutil import now_kst
|
||
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", True, raising=False)
|
||
env = _env(monkeypatch, price=10050, high_vol=False, signal=None, order=OK,
|
||
at_kwargs={"strategy_name": "synced", "entry_reason": _EXT,
|
||
"created_at": now_kst().replace(tzinfo=None) - timedelta(days=1)})
|
||
monkeypatch.setattr(env.es, "check_exit_signal", lambda **kw: None)
|
||
old_buy = now_kst().replace(tzinfo=None) - timedelta(days=int(settings.SWING_TIME_STOP_DAYS) * 2)
|
||
db = _RowsDB([("BUY", 10, 10, old_buy)], []) # ① 시간손절 진입일 ② 누적 실현손익(없음)
|
||
await _cycle(env.es, db)
|
||
assert env.sent == [10]
|
||
assert env.history[0]["exit_reason"].startswith("시간손절")
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# #4 — 포지션 단위 순손익 승패 / #6 — commission·net 기록
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
def _cost(entry, exit_, q):
|
||
from app.services.trade_helpers import calculate_trade_costs
|
||
return calculate_trade_costs(entry, exit_, q, True, "KOSPI")["total_cost"]
|
||
|
||
|
||
def test_position_is_loss_v2():
|
||
import app.services.swing.exit_service as es
|
||
assert es._position_is_loss_v2(8000.0, -410.0) is False
|
||
assert es._position_is_loss_v2(None, -410.0) is True
|
||
assert es._position_is_loss_v2(-1.0, 0.5) is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_prior_realized_net_converts_gross_rows_and_uses_net_rows():
|
||
import app.services.swing.exit_service as es
|
||
# 행: (net_profit_loss, commission, total_amount, executed_quantity, order_quantity)
|
||
# ① 과거 gross 기록(commission NULL): 진입 10,000 → 10,500 × 10 = gross 5,000
|
||
# ② 비용 기록 행: net 그대로
|
||
rows = [(5000.0, None, 105000.0, 10, 10), (1234.0, 50.0, 99999.0, 5, 5), (None, None, 1, 1, 1)]
|
||
at = SimpleNamespace(created_at=datetime(2026, 10, 1), entry_reason=None, strategy_name="swing")
|
||
db = _RowsDB(rows)
|
||
got = await es._prior_position_realized_net_v2(db, "005930", at, "KRX")
|
||
assert got == pytest.approx(5000.0 - _cost(10000.0, 10500.0, 10) + 1234.0)
|
||
sql = _sql(db.statements[0])
|
||
assert "trade_history.order_type = 'SELL'" in sql and "trade_history.created_at >= '2026-10-01" in sql
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_prior_realized_net_failure_returns_none():
|
||
import app.services.swing.exit_service as es
|
||
at = SimpleNamespace(created_at=datetime(2026, 10, 1), entry_reason=None, strategy_name="swing")
|
||
assert await es._prior_position_realized_net_v2(_BoomDB(), "005930", at, "KRX") is None
|
||
assert await es._prior_position_realized_net_v2(_RowsDB(), "005930", None, "KRX") is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_exit_after_profitable_partials_is_not_loss(monkeypatch):
|
||
"""449450 재현: 1차 익절 2회(+3,900/+4,125) 후 잔량 트레일링 소폭 손실 → 포지션은 이익 → 패 아님."""
|
||
env = _env(monkeypatch, price=9990, high_vol=False, order=OK,
|
||
signal=ExitSignal("SELL_ALL", "전량청산: 트레일링", 1.0))
|
||
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False)
|
||
db = _RowsDB([(3900.0, None, 0, 0, 0), (4125.0, None, 0, 0, 0)])
|
||
await _cycle(env.es, db)
|
||
assert env.sent == [10]
|
||
env.loss.assert_awaited_once()
|
||
assert env.loss.await_args.kwargs["is_loss"] is False
|
||
# #6: commission·순손익 기록
|
||
h = env.history[0]
|
||
assert h["commission"] == _cost(10000.0, 9990.0, 10)
|
||
assert h["net_profit_loss"] == pytest.approx((9990 - 10000) * 10 - _cost(10000.0, 9990.0, 10))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_exit_tiny_gross_profit_within_costs_is_loss(monkeypatch):
|
||
"""비용 이내 미세 이익(+0.05%) 단일 레그 → 순손익 음수 → 패(§5.1)."""
|
||
env = _env(monkeypatch, price=10005, high_vol=False, order=OK,
|
||
signal=ExitSignal("SELL_ALL", "BB상단", 1.0))
|
||
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False)
|
||
await _cycle(env.es, _RowsDB([]))
|
||
assert env.loss.await_args.kwargs["is_loss"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_exit_prior_lookup_failure_still_sells_and_records(monkeypatch):
|
||
env = _env(monkeypatch, price=9000, high_vol=False, order=OK,
|
||
signal=ExitSignal("SELL_ALL", "손절", 1.0))
|
||
monkeypatch.setattr(settings, "SWING_TIME_STOP_ENABLED", False, raising=False)
|
||
await _cycle(env.es, _BoomDB())
|
||
assert env.sent == [10]
|
||
assert env.loss.await_args.kwargs["is_loss"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unknown_reconcile_uses_prior_realized_net(monkeypatch):
|
||
import app.services.swing.exit_service as es
|
||
import app.services.portfolio_cache_bridge as pcb
|
||
monkeypatch.setattr(es, "enqueue_trade_history", AsyncMock())
|
||
monkeypatch.setattr(es, "enqueue_close_active_trade", AsyncMock())
|
||
monkeypatch.setattr(pcb, "reflect_sell", AsyncMock())
|
||
loss = AsyncMock()
|
||
monkeypatch.setattr(es.loss_tracker, "record_trade_result_safe", loss)
|
||
monkeypatch.setattr(es.loss_tracker, "save_to_db", AsyncMock())
|
||
es._pending_unknown_sells["005930"] = {
|
||
"qty": 10, "pre_hldg": 10, "since": time.time(), "price": 9990.0, "avg_price": 10000.0,
|
||
"swing_qty": 10, "sell_pct": 1.0, "remaining_pct": 0.5, "original_qty": 20,
|
||
"strategy": "swing", "filter_not_dt": False, "had_active_trade": True,
|
||
"stock_name": "T", "reason": "트레일링", "market": "KRX", "prior_realized_net": 8000.0,
|
||
}
|
||
assert await es._reconcile_unknown_sell_v2("005930", 0, 0) is True
|
||
assert loss.await_args.kwargs["is_loss"] is False
|
||
|
||
|
||
from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지)
|